From db568d471026d68f45bf1975dc27f572fe45961b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 20 Mar 2020 14:04:11 -0700 Subject: [PATCH 01/91] initial implementation of Java common SDK code (#1) --- .circleci/config.yml | 98 +++ .ldrelease/config.yml | 15 + .ldrelease/publish-docs.sh | 7 + .ldrelease/publish.sh | 7 + CHANGELOG.md | 3 + CONTRIBUTING.md | 41 ++ LICENSE | 13 + README.md | 29 + build.gradle | 173 +++++ checkstyle.xml | 15 + gradle.properties | 5 + gradle.properties.example | 8 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 55616 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 188 +++++ gradlew.bat | 100 +++ settings.gradle | 1 + .../com/launchdarkly/sdk/ArrayBuilder.java | 95 +++ .../launchdarkly/sdk/EvaluationDetail.java | 111 +++ .../launchdarkly/sdk/EvaluationReason.java | 366 ++++++++++ .../java/com/launchdarkly/sdk/Helpers.java | 48 ++ .../java/com/launchdarkly/sdk/LDUser.java | 678 ++++++++++++++++++ .../java/com/launchdarkly/sdk/LDValue.java | 609 ++++++++++++++++ .../com/launchdarkly/sdk/LDValueArray.java | 56 ++ .../com/launchdarkly/sdk/LDValueBool.java | 41 ++ .../com/launchdarkly/sdk/LDValueNull.java | 29 + .../com/launchdarkly/sdk/LDValueNumber.java | 68 ++ .../com/launchdarkly/sdk/LDValueObject.java | 58 ++ .../com/launchdarkly/sdk/LDValueString.java | 39 + .../com/launchdarkly/sdk/LDValueType.java | 34 + .../launchdarkly/sdk/LDValueTypeAdapter.java | 53 ++ .../com/launchdarkly/sdk/ObjectBuilder.java | 106 +++ .../com/launchdarkly/sdk/UserAttribute.java | 151 ++++ .../com/launchdarkly/sdk/package-info.java | 4 + .../sdk/EvaluationReasonTest.java | 89 +++ .../java/com/launchdarkly/sdk/LDUserTest.java | 270 +++++++ .../com/launchdarkly/sdk/LDValueTest.java | 447 ++++++++++++ .../com/launchdarkly/sdk/TestHelpers.java | 20 + .../launchdarkly/sdk/UserAttributeTest.java | 70 ++ 39 files changed, 4150 insertions(+) create mode 100644 .circleci/config.yml create mode 100644 .ldrelease/config.yml create mode 100755 .ldrelease/publish-docs.sh create mode 100755 .ldrelease/publish.sh create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.gradle create mode 100644 checkstyle.xml create mode 100644 gradle.properties create mode 100644 gradle.properties.example create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/com/launchdarkly/sdk/ArrayBuilder.java create mode 100644 src/main/java/com/launchdarkly/sdk/EvaluationDetail.java create mode 100644 src/main/java/com/launchdarkly/sdk/EvaluationReason.java create mode 100644 src/main/java/com/launchdarkly/sdk/Helpers.java create mode 100644 src/main/java/com/launchdarkly/sdk/LDUser.java create mode 100644 src/main/java/com/launchdarkly/sdk/LDValue.java create mode 100644 src/main/java/com/launchdarkly/sdk/LDValueArray.java create mode 100644 src/main/java/com/launchdarkly/sdk/LDValueBool.java create mode 100644 src/main/java/com/launchdarkly/sdk/LDValueNull.java create mode 100644 src/main/java/com/launchdarkly/sdk/LDValueNumber.java create mode 100644 src/main/java/com/launchdarkly/sdk/LDValueObject.java create mode 100644 src/main/java/com/launchdarkly/sdk/LDValueString.java create mode 100644 src/main/java/com/launchdarkly/sdk/LDValueType.java create mode 100644 src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java create mode 100644 src/main/java/com/launchdarkly/sdk/ObjectBuilder.java create mode 100644 src/main/java/com/launchdarkly/sdk/UserAttribute.java create mode 100644 src/main/java/com/launchdarkly/sdk/package-info.java create mode 100644 src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/LDUserTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/LDValueTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/TestHelpers.java create mode 100644 src/test/java/com/launchdarkly/sdk/UserAttributeTest.java diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..d329a1e --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,98 @@ +version: 2.1 + +orbs: + win: circleci/windows@1.0.0 + +workflows: + test: + jobs: + - build-linux + - test-linux: + name: Java 8 - Linux - OpenJDK + docker-image: circleci/openjdk:8 + requires: + - build-linux + - test-linux: + name: Java 9 - Linux - OpenJDK + docker-image: circleci/openjdk:9 + requires: + - build-linux + - test-linux: + name: Java 10 - Linux - OpenJDK + docker-image: circleci/openjdk:10 + requires: + - build-linux + - test-linux: + name: Java 11 - Linux - OpenJDK + docker-image: circleci/openjdk:11 + requires: + - build-linux + - build-test-windows: + name: Java 11 - Windows - OpenJDK + +jobs: + build-linux: + docker: + - image: circleci/openjdk:8u131-jdk # To match the version pre-installed in Ubuntu 16 and used by Jenkins for releasing + steps: + - checkout + - run: cp gradle.properties.example gradle.properties + - run: java -version + - run: ./gradlew dependencies + - run: ./gradlew jar + - run: ./gradlew checkstyleMain + - persist_to_workspace: + root: build + paths: + - classes + + test-linux: + parameters: + docker-image: + type: string + docker: + - image: <> + steps: + - checkout + - run: cp gradle.properties.example gradle.properties + - attach_workspace: + at: build + - run: java -version + - run: ./gradlew test + - run: + name: Save test results + command: | + mkdir -p ~/junit/; + find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ \; + when: always + - store_test_results: + path: ~/junit + - store_artifacts: + path: ~/junit + + build-test-windows: + executor: + name: win/vs2019 + shell: powershell.exe + steps: + - checkout + - run: + name: install OpenJDK + command: | + $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: build and test + command: | + cp gradle.properties.example gradle.properties + .\gradlew.bat --no-daemon test # must use --no-daemon because CircleCI in Windows will hang if there's a daemon running + - run: + name: save test results + command: | + mkdir .\junit + cp build/test-results/test/*.xml junit + - store_test_results: + path: .\junit + - store_artifacts: + path: .\junit diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml new file mode 100644 index 0000000..c1ca1e8 --- /dev/null +++ b/.ldrelease/config.yml @@ -0,0 +1,15 @@ +repo: + public: java-sdk-common + private: java-sdk-common-private + +publications: + - url: https://oss.sonatype.org/content/groups/public/com/launchdarkly/launchdarkly-java-sdk-common/ + description: Sonatype + - url: https://javadoc.io/doc/com.launchdarkly/launchdarkly-java-sdk-common + description: documentation (javadoc.io) + +template: + name: gradle + +documentation: + githubPages: true diff --git a/.ldrelease/publish-docs.sh b/.ldrelease/publish-docs.sh new file mode 100755 index 0000000..81e1bb4 --- /dev/null +++ b/.ldrelease/publish-docs.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -ue + +# Publish to Github Pages +echo "Publishing to Github Pages" +./gradlew gitPublishPush diff --git a/.ldrelease/publish.sh b/.ldrelease/publish.sh new file mode 100755 index 0000000..a2e9637 --- /dev/null +++ b/.ldrelease/publish.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -ue + +# Publish to Sonatype +echo "Publishing to Sonatype" +./gradlew publishToSonatype closeAndReleaseRepository || { echo "Gradle publish/release failed" >&2; exit 1; } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..22477f5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Change log + +All notable changes to the project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..afd1ad6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing to the LaunchDarkly SDK Java Common Code + +LaunchDarkly has published an [SDK contributor's guide](https://docs.launchdarkly.com/docs/sdk-contributors-guide) that provides a detailed explanation of how our SDKs work. See below for additional information on how to contribute to this project. + +## Submitting bug reports and feature requests + +The LaunchDarkly SDK team monitors the [issue tracker](https://github.com/launchdarkly/java-sdk-common/issues) in the GitHub repository. Bug reports and feature requests specific to this project should be filed in this issue tracker. The SDK team will respond to all newly filed issues within two business days. + +## Submitting pull requests + +We encourage pull requests and other contributions from the community. Before submitting pull requests, ensure that all temporary or unintended code is removed. Don't worry about adding reviewers to the pull request; the LaunchDarkly SDK team will add themselves. The SDK team will acknowledge all pull requests within two business days. + +## Build instructions + +### Prerequisites + +The project builds with [Gradle](https://gradle.org/) and should be built against Java 8. + +### Building + +To build the project without running any tests: +``` +./gradlew jar +``` + +If you wish to clean your working directory between builds, you can clean it by running: +``` +./gradlew clean +``` + +If you wish to use your generated SDK artifact by another Maven/Gradle project such as [java-server-sdk](https://github.com/launchdarkly/java-server-sdk), you will likely want to publish the artifact to your local Maven repository so that your other project can access it. +``` +./gradlew publishToMavenLocal +``` + +### Testing + +To build the project and run all unit tests: +``` +./gradlew test +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1289766 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2020 Catamorphic, Co. + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad8c04b --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# LaunchDarkly SDK Java Common Code + +[![Circle CI](https://circleci.com/gh/launchdarkly/java-sdk-common.svg?style=shield)](https://circleci.com/gh/launchdarkly/java-sdk-common) +[![Javadocs](http://javadoc.io/badge/com.launchdarkly/launchdarkly-java-sdk-common.svg)](http://javadoc.io/doc/com.launchdarkly/launchdarkly-java-sdk-common) + +This project contains Java classes and interfaces that are shared between the LaunchDarkly server-side Java SDK and the LaunchDarkly Android SDK. Code that is specific to one or the other is in [java-server-sdk](https://github.com/launchdarkly/java-server-sdk) or [android-client-sdk](https://github.com/launchdarkly/android-client-sdk). + +## Supported Java versions + +This version of the library works with Java 8 and above. + +## Contributing + +See [Contributing](https://github.com/launchdarkly/dotnet-sdk-common/blob/master/CONTRIBUTING.md). + +## 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. + * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/docs) for a complete list. +* Explore LaunchDarkly + * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information + * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides + * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation + * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates + * [Feature Flagging Guide](https://github.com/launchdarkly/featureflags/ "Feature Flagging Guide") for best practices and strategies diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..be0ead2 --- /dev/null +++ b/build.gradle @@ -0,0 +1,173 @@ + +buildscript { + repositories { + mavenCentral() + mavenLocal() + } +} + +plugins { + id "java" + id "java-library" + id "checkstyle" + id "signing" + id "maven-publish" + id "de.marcphilipp.nexus-publish" version "0.3.0" + id "io.codearte.nexus-staging" version "0.21.2" + id "org.ajoberstar.git-publish" version "2.1.3" + id "idea" +} + +repositories { + mavenLocal() + // Before LaunchDarkly release artifacts get synced to Maven Central they are here along with snapshots: + maven { url "https://oss.sonatype.org/content/groups/public/" } + mavenCentral() +} + +configurations.all { + // check for updates every build for dependencies with: 'changing: true' + resolutionStrategy.cacheChangingModulesFor 0, 'seconds' +} + +allprojects { + group = 'com.launchdarkly' + version = "${version}" + archivesBaseName = "launchdarkly-java-sdk-common" + sourceCompatibility = 1.8 + targetCompatibility = 1.8 +} + +ext { + sdkBasePackage = "com.launchdarkly.sdk" + sdkBaseName = "launchdarkly-java-sdk-common" +} + +ext.versions = [ + "gson": "2.7" +] + +ext.libraries = [:] + +dependencies { + api "com.google.code.gson:gson:${versions.gson}" + testImplementation "org.hamcrest:hamcrest-all:1.3" + testImplementation "junit:junit:4.12" +} + +checkstyle { + configFile file("${project.rootDir}/checkstyle.xml") +} + +// custom tasks for creating source/javadoc jars +task sourcesJar(type: Jar, dependsOn: classes) { + classifier = 'sources' + from sourceSets.main.allSource +} + +task javadocJar(type: Jar, dependsOn: javadoc) { + classifier = 'javadoc' + from javadoc.destinationDir +} + +javadoc { + // Force the Javadoc build to fail if there are any Javadoc warnings. See: https://discuss.gradle.org/t/javadoc-fail-on-warning/18141/3 + // The '-quiet' as second argument is actually a hack, + // since the one parameter 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') +} + +artifacts { + archives jar, sourcesJar, javadocJar +} + +test { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + showStandardStreams = true + exceptionFormat = 'full' + } +} + +idea { + module { + downloadJavadoc = true + downloadSources = true + } +} + +nexusStaging { + packageGroup = "com.launchdarkly" + numberOfRetries = 40 // we've seen extremely long delays in closing repositories +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + + groupId = 'com.launchdarkly' + artifactId = 'launchdarkly-java-sdk-common' + + artifact sourcesJar + artifact javadocJar + + pom { + name = 'launchdarkly-java-sdk-common' + description = 'LaunchDarkly SDK Java Common Classes' + url = 'https://github.com/launchdarkly/java-sdk-common' + licenses { + license { + name = 'The Apache License, Version 2.0' + url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + } + } + developers { + developer { + name = 'LaunchDarkly' + email = 'team@launchdarkly.com' + } + } + scm { + connection = 'scm:git:git://github.com/launchdarkly/java-sdk-common.git' + developerConnection = 'scm:git:ssh:git@github.com:launchdarkly/java-sdk-common.git' + url = 'https://github.com/launchdarkly/java-sdk-common' + } + } + } + } + repositories { + mavenLocal() + } +} + +nexusPublishing { + clientTimeout = java.time.Duration.ofMinutes(2) // we've seen extremely long delays in creating repositories + repositories { + sonatype { + username = ossrhUsername + password = ossrhPassword + } + } +} + +signing { + sign publishing.publications.mavenJava +} + +tasks.withType(Sign) { + onlyIf { !"1".equals(project.findProperty("LD_SKIP_SIGNING")) } // so we can build jars for testing in CI +} + +gitPublish { + repoUri = 'git@github.com:launchdarkly/java-sdk-common.git' + branch = 'gh-pages' + contents { + from javadoc + } + commitMessage = 'publishing javadocs' +} diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..0b201f9 --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..09f090e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +version=1.0.0-SNAPSHOT +# The following empty ossrh properties are used by LaunchDarkly's internal integration testing framework +# and should not be needed for typical development purposes (including by third-party developers). +ossrhUsername= +ossrhPassword= diff --git a/gradle.properties.example b/gradle.properties.example new file mode 100644 index 0000000..058697d --- /dev/null +++ b/gradle.properties.example @@ -0,0 +1,8 @@ +# To release a version of this SDK, copy this file to ~/.gradle/gradle.properties and fill in the values. +githubUser = YOUR_GITHUB_USERNAME +githubPassword = YOUR_GITHUB_PASSWORD +signing.keyId = 5669D902 +signing.password = SIGNING_PASSWORD +signing.secretKeyRingFile = SECRET_RING_FILE +ossrhUsername = launchdarkly +ossrhPassword = OSSHR_PASSWORD diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..5c2d1cf016b3885f6930543d57b744ea8c220a1a GIT binary patch literal 55616 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 \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..24467a1 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,100 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..a9e03eb --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'launchdarkly-java-sdk-common' diff --git a/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java b/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java new file mode 100644 index 0000000..08d2d6c --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java @@ -0,0 +1,95 @@ +package com.launchdarkly.sdk; + +import java.util.ArrayList; +import java.util.List; + +/** + * A builder created by {@link LDValue#buildArray()}. + *

+ * Builder methods are not thread-safe. + * + * @since 4.8.0 + */ +public final class ArrayBuilder { + private List builder = new ArrayList<>(); + private volatile boolean copyOnWrite = false; + + /** + * Adds a new element to the builder. + * @param value the new element + * @return the same builder + */ + public ArrayBuilder add(LDValue value) { + if (copyOnWrite) { + builder = new ArrayList<>(builder); + copyOnWrite = false; + } + 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() { + copyOnWrite = true; + return LDValueArray.fromList(builder); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java new file mode 100644 index 0000000..aa3dcae --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java @@ -0,0 +1,111 @@ +package com.launchdarkly.sdk; + +import static com.launchdarkly.sdk.Helpers.hashFrom; +import static com.launchdarkly.sdk.Helpers.objectsEqual; + +/** + * An object returned by the SDK's "variation detail" methods such as {@code boolVariationDetail}, + * 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); + } + + /** + * Shortcut for creating an instance with an error result. + * + * @param errorKind the type of error + * @param defaultValue the application default value + * @return an {@link EvaluationDetail} + */ + public 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 objectsEqual(reason, o.reason) && objectsEqual(variationIndex, o.variationIndex) && objectsEqual(value, o.value); + } + return false; + } + + @Override + public int hashCode() { + return hashFrom(reason, variationIndex, value); + } + + @Override + public String toString() { + return "{" + reason + "," + variationIndex + "," + value + "}"; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java new file mode 100644 index 0000000..4f33997 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -0,0 +1,366 @@ +package com.launchdarkly.sdk; + +import java.util.Objects; + +/** + * Describes the reason that a flag evaluation produced a particular value. + *

+ * This is returned within {@link EvaluationDetail} by the SDK's "variation detail" methods such as + * {@code boolVariationDetail}. + *

+ * Note that this is an enum-like class hierarchy rather than an enum, because some of the + * possible reasons have their own 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 {@code boolVariationDetail} + * 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; + } + + @Override + public String toString() { + return getKind().name(); + } + + protected EvaluationReason(Kind kind) + { + this.kind = kind; + } + + /** + * Returns an instance of {@link Off}. + * @return a reason object + */ + public static Off off() { + return Off.instance; + } + + /** + * Returns an instance of {@link TargetMatch}. + * @return a reason object + */ + public static TargetMatch targetMatch() { + return TargetMatch.instance; + } + + /** + * Returns an instance of {@link RuleMatch}. + * @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 of {@link PrerequisiteFailed}. + * @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 of {@link Fallthrough}. + * @return a reason object + */ + public static Fallthrough fallthrough() { + return Fallthrough.instance; + } + + /** + * Returns an instance of {@link 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + public static class PrerequisiteFailed extends EvaluationReason { + private final String prerequisiteKey; + + private PrerequisiteFailed(String prerequisiteKey) { + super(Kind.PREREQUISITE_FAILED); + this.prerequisiteKey = prerequisiteKey; + } + + /** + * The key of the prerequisite flag that did not return the desired variation. + * @return the prerequisite flag key + */ + 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 + */ + 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 + */ + 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); + this.errorKind = errorKind; + this.exception = exception; + } + + /** + * An enumeration value indicating the general category of error. + * @return the error kind + */ + 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/sdk/Helpers.java b/src/main/java/com/launchdarkly/sdk/Helpers.java new file mode 100644 index 0000000..0882471 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/Helpers.java @@ -0,0 +1,48 @@ +package com.launchdarkly.sdk; + +import java.util.Iterator; +import java.util.function.Function; + +/** + * Internal helper classes that serve the same purpose as Guava helpers. We do not use Guava in this + * library because the Android SDK does not have it. + */ +abstract class Helpers { + static boolean objectsEqual(Object a, Object b) { + if (a == null) { + return b == null; + } else { + return b != null && a.equals(b); + } + } + + static int hashFrom(Object... values) { + int result = 0; + for (Object value: values) { + result = result * 31 + (value == null ? 0 : value.hashCode()); + } + return result; + } + + // This implementation is much simpler than Guava's Iterables.transform() because it does not attempt + // to support remove(). + static Iterable transform(final Iterable source, final Function fn) { + return new Iterable() { + @Override + public Iterator iterator() { + final Iterator sourceIterator = source.iterator(); + return new Iterator() { + @Override + public boolean hasNext() { + return sourceIterator.hasNext(); + } + + @Override + public U next() { + return fn.apply(sourceIterator.next()); + } + }; + } + }; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java new file mode 100644 index 0000000..1070379 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -0,0 +1,678 @@ +package com.launchdarkly.sdk; + +import com.google.gson.Gson; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static com.launchdarkly.sdk.Helpers.hashFrom; +import static com.launchdarkly.sdk.Helpers.objectsEqual; +import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableMap; +import static java.util.Collections.unmodifiableSet; + +/** + * A collection of attributes that can affect flag evaluation, usually corresponding to a user of your application. + *

+ * The only mandatory property is the {@code key}, which must uniquely identify each user; this could be a username + * or email address for authenticated users, or a session ID for anonymous users. All other built-in properties are + * optional. You may also define custom properties with arbitrary names and values. + *

+ * For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference + * guides on Setting user attributes + * and Targeting users. + *

+ * If you want to pass an LDUser object to the front end to be used with the JavaScript SDK, call {@link #toJsonString()} + * to get its JSON encoding. Do not try to pass an LDUser instance to a reflection-based encoder such as Gson; its + * internal structure does not correspond directly to the JSON encoding, and an external instance of Gson will not + * recognize the Gson annotations used inside the SDK. + */ +public class LDUser { + private static final Gson defaultGson = new Gson(); + + // 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. + final LDValue key; + final LDValue secondary; + final LDValue ip; + final LDValue email; + final LDValue name; + final LDValue avatar; + final LDValue firstName; + final LDValue lastName; + final LDValue anonymous; + final LDValue country; + final Map custom; + Set privateAttributeNames; + + protected LDUser(Builder builder) { + 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 : unmodifiableMap(builder.custom); + this.privateAttributeNames = builder.privateAttributes == null ? null : unmodifiableSet(builder.privateAttributes); + } + + /** + * 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; + } + + /** + * Returns the user's unique key. + * + * @return the user key as a string + */ + public String getKey() { + return key.stringValue(); + } + + /** + * Returns the value of the secondary key property for the user, if set. + * + * @return a string or null + */ + public String getSecondary() { + return secondary.stringValue(); + } + + /** + * Returns the value of the IP property for the user, if set. + * + * @return a string or null + */ + public String getIp() { + return ip.stringValue(); + } + + /** + * Returns the value of the country property for the user, if set. + * + * @return a string or null + */ + public String getCountry() { + return country.stringValue(); + } + + /** + * Returns the value of the full name property for the user, if set. + * + * @return a string or null + */ + public String getName() { + return name.stringValue(); + } + + /** + * Returns the value of the first name property for the user, if set. + * + * @return a string or null + */ + public String getFirstName() { + return firstName.stringValue(); + } + + /** + * Returns the value of the last name property for the user, if set. + * + * @return a string or null + */ + public String getLastName() { + return lastName.stringValue(); + } + + /** + * Returns the value of the email property for the user, if set. + * + * @return a string or null + */ + public String getEmail() { + return email.stringValue(); + } + + /** + * Returns the value of the avatar property for the user, if set. + * + * @return a string or null + */ + public String getAvatar() { + return avatar.stringValue(); + } + + /** + * Returns true if this user was marked anonymous. + * + * @return true for an anonymous user + */ + public boolean isAnonymous() { + return anonymous.booleanValue(); + } + + /** + * Gets the value of a user attribute, if present. + *

+ * This can be either a built-in attribute or a custom one. It returns the value using the {@link LDValue} + * type, which can have any type that is supported in JSON. If the attribute does not exist, it returns + * {@link LDValue#ofNull()}. + * + * @param attribute the attribute to get + * @return the attribute value or {@link LDValue#ofNull()}; will never be an actual null reference + */ + public LDValue getAttribute(UserAttribute attribute) { + if (attribute.isBuiltIn()) { + return attribute.builtInGetter.apply(this); + } else { + return custom == null ? LDValue.ofNull() : LDValue.normalize(custom.get(attribute)); + } + } + + /** + * Returns an enumeration of all custom attribute names that were set for this user. + * + * @return the custom attribute names + */ + public Iterable getCustomAttributes() { + return custom == null ? emptyList() : custom.keySet(); + } + + /** + * Returns an enumeration of all attributes that were marked private for this user. + *

+ * This does not include any attributes that were globally marked private in your SDK configuration. + * + * @return the names of private attributes for this user + */ + public Iterable getPrivateAttributes() { + return privateAttributeNames == null ? emptyList() : privateAttributeNames; + } + + /** + * Tests whether an attribute has been marked private for this user. + * + * @param attribute a built-in or custom attribute + * @return true if the attribute was marked private on a per-user level + */ + public boolean isAttributePrivate(UserAttribute attribute) { + return privateAttributeNames != null && privateAttributeNames.contains(attribute); + } + + /** + * Converts the user data to its standard JSON representation. + *

+ * This is the same format that the LaunchDarkly JavaScript browser SDK uses to represent users, so + * it is the simplest way to pass user data to front-end code. + *

+ * Do not pass the {@link LDUser} object to a reflection-based JSON encoder such as Gson. Although the + * SDK uses Gson internally, it uses shading so that the Gson types are not exposed, so an external + * instance of Gson will not recognize the type adapters that provide the correct format. + * + * @return a JSON representation of the user + */ + public String toJsonString() { + return defaultGson.toJson(this); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + LDUser ldUser = (LDUser) o; + + return objectsEqual(key, ldUser.key) && + objectsEqual(secondary, ldUser.secondary) && + objectsEqual(ip, ldUser.ip) && + objectsEqual(email, ldUser.email) && + objectsEqual(name, ldUser.name) && + objectsEqual(avatar, ldUser.avatar) && + objectsEqual(firstName, ldUser.firstName) && + objectsEqual(lastName, ldUser.lastName) && + objectsEqual(anonymous, ldUser.anonymous) && + objectsEqual(country, ldUser.country) && + objectsEqual(custom, ldUser.custom) && + objectsEqual(privateAttributeNames, ldUser.privateAttributeNames); + } + + @Override + public int hashCode() { + return hashFrom(key, secondary, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); + } + + /** + * 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 privateAttributes; + + /** + * 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.key.stringValue(); + this.secondary = user.secondary.stringValue(); + this.ip = user.ip.stringValue(); + this.firstName = user.firstName.stringValue(); + this.lastName = user.lastName.stringValue(); + this.email = user.email.stringValue(); + this.name = user.name.stringValue(); + this.avatar = user.avatar.stringValue(); + this.anonymous = user.anonymous.isNull() ? null : user.anonymous.booleanValue(); + this.country = user.country.stringValue(); + this.custom = user.custom == null ? null : new HashMap<>(user.custom); + this.privateAttributes = 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(UserAttribute.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(UserAttribute.SECONDARY_KEY); + return secondary(s); + } + + /** + * Set the country for a user. Before version 5.0.0, this field was validated and normalized by the SDK + * as an ISO-3166-1 country code before assignment. This behavior has been removed so that the SDK can + * treat this field as a normal string, leaving the meaning of this field up to the application. + * + * @param s the country for the user + * @return the builder + */ + public Builder country(String s) { + this.country = s; + return this; + } + + /** + * Set the country for a user, and ensures that the country attribute will not be sent back to LaunchDarkly. + * Before version 5.0.0, this field was validated and normalized by the SDK as an ISO-3166-1 country code + * before assignment. This behavior has been removed so that the SDK can treat this field as a normal string, + * leaving the meaning of this field up to the application. + * + * @param s the country for the user + * @return the builder + */ + public Builder privateCountry(String s) { + addPrivate(UserAttribute.COUNTRY); + return country(s); + } + + /** + * 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(UserAttribute.FIRST_NAME); + 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(UserAttribute.LAST_NAME); + 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(UserAttribute.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(UserAttribute.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(UserAttribute.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, LDValue.of(v)); + } + + /** + * Adds an integer-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, int n) { + return custom(k, LDValue.of(n)); + } + + /** + * Adds a double-precision numeric 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, double n) { + return custom(k, LDValue.of(n)); + } + + /** + * Add a 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, LDValue.of(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) { + if (k != null) { + return customInternal(UserAttribute.forName(k), v); + } + return this; + } + + private Builder customInternal(UserAttribute a, LDValue v) { + if (custom == null) { + custom = new HashMap<>(); + } + custom.put(a, LDValue.normalize(v)); + return this; + } + + /** + * 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) { + return privateCustom(k, LDValue.of(v)); + } + + /** + * Add an int-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, int n) { + return privateCustom(k, LDValue.of(n)); + } + + /** + * Add a double-precision numeric 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, double n) { + return privateCustom(k, LDValue.of(n)); + } + + /** + * Add a 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) { + return privateCustom(k, LDValue.of(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) { + if (k != null) { + UserAttribute a = UserAttribute.forName(k); + addPrivate(a); + return customInternal(a, v); + } + return this; + } + + private void addPrivate(UserAttribute attribute) { + if (privateAttributes == null) { + privateAttributes = new HashSet<>(); + } + privateAttributes.add(attribute); + } + + /** + * Builds the configured {@link LDUser} object. + * + * @return the {@link LDUser} configured by this builder + */ + public LDUser build() { + return new LDUser(this); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java new file mode 100644 index 0000000..170a450 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -0,0 +1,609 @@ +package com.launchdarkly.sdk; + +import com.google.gson.Gson; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.util.Map; + +import static com.launchdarkly.sdk.Helpers.transform; +import static java.util.Collections.emptyList; + +/** + * An immutable instance of any data type that is allowed in JSON. + *

+ * An {@link LDValue} instance can be a null (that is, an instance that represents a JSON null value, + * rather than a Java null reference), a boolean, a number (always encoded internally as double-precision + * floating-point, but can be treated as an integer), a string, an ordered list of {@link LDValue} + * values (a JSON array), or a map of strings to {@link LDValue} values (a JSON object). It is easily + * convertible to standard Java types. + *

+ * This can be used to represent complex data in a user custom attribute (see {@link LDUser.Builder#custom(String, LDValue)}), + * or to get a feature flag value that uses a complex type or that does not always use the same + * type (see the client's {@code jsonValueVariation} methods). + *

+ * While the LaunchDarkly SDK uses Gson internally for JSON parsing, it uses {@link LDValue} rather + * than Gson's {@code JsonElement} type for two reasons. First, this allows Gson types to be excluded + * from the API, so the SDK does not expose this dependency and cannot cause version conflicts in + * applications that use Gson themselves. Second, Gson's array and object types are mutable, which can + * cause concurrency risks. + * + * @since 4.8.0 + */ +@JsonAdapter(LDValueTypeAdapter.class) +public abstract class LDValue { + static final Gson gson = new Gson(); + + /** + * 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(); + } + + /** + * Parses an LDValue from a JSON representation. + * @param json a JSON string + * @return an LDValue + */ + public static LDValue parse(String json) { + return gson.fromJson(json, LDValue.class); + } + + /** + * 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 emptyList(); + } + + /** + * 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 emptyList(); + } + + /** + * 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 transform(values(), converter::toType); + } + + /** + * 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); + } + + abstract void write(JsonWriter writer) throws IOException; + + static boolean isInteger(double value) { + return value == (double)((int)value); + } + + @Override + public String toString() { + return toJsonString(); + } + + @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/sdk/LDValueArray.java b/src/main/java/com/launchdarkly/sdk/LDValueArray.java new file mode 100644 index 0000000..f61385a --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDValueArray.java @@ -0,0 +1,56 @@ +package com.launchdarkly.sdk; + +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.util.List; + +import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableList; + +@JsonAdapter(LDValueTypeAdapter.class) +final class LDValueArray extends LDValue { + private static final LDValueArray EMPTY = new LDValueArray(emptyList()); + private final List list; + // Note that this is not + + static LDValueArray fromList(List list) { + return list.isEmpty() ? EMPTY : new LDValueArray(list); + } + + private LDValueArray(List list) { + this.list = unmodifiableList(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(); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/LDValueBool.java b/src/main/java/com/launchdarkly/sdk/LDValueBool.java new file mode 100644 index 0000000..f68789e --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDValueBool.java @@ -0,0 +1,41 @@ +package com.launchdarkly.sdk; + +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 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); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/LDValueNull.java b/src/main/java/com/launchdarkly/sdk/LDValueNull.java new file mode 100644 index 0000000..1b3246d --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDValueNull.java @@ -0,0 +1,29 @@ +package com.launchdarkly.sdk; + +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(); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/LDValueNumber.java b/src/main/java/com/launchdarkly/sdk/LDValueNumber.java new file mode 100644 index 0000000..4c5c2ba --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDValueNumber.java @@ -0,0 +1,68 @@ +package com.launchdarkly.sdk; + +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); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/LDValueObject.java b/src/main/java/com/launchdarkly/sdk/LDValueObject.java new file mode 100644 index 0000000..33d1d35 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDValueObject.java @@ -0,0 +1,58 @@ +package com.launchdarkly.sdk; + +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.util.Map; + +import static java.util.Collections.emptyMap; + +@JsonAdapter(LDValueTypeAdapter.class) +final class LDValueObject extends LDValue { + private static final LDValueObject EMPTY = new LDValueObject(emptyMap()); + 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(); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/LDValueString.java b/src/main/java/com/launchdarkly/sdk/LDValueString.java new file mode 100644 index 0000000..dcdeb4e --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDValueString.java @@ -0,0 +1,39 @@ +package com.launchdarkly.sdk; + +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); + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/sdk/LDValueType.java b/src/main/java/com/launchdarkly/sdk/LDValueType.java new file mode 100644 index 0000000..11804f6 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDValueType.java @@ -0,0 +1,34 @@ +package com.launchdarkly.sdk; + +/** + * 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/sdk/LDValueTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java new file mode 100644 index 0000000..acdfa7c --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java @@ -0,0 +1,53 @@ +package com.launchdarkly.sdk; + +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/sdk/ObjectBuilder.java b/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java new file mode 100644 index 0000000..cb0157e --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java @@ -0,0 +1,106 @@ +package com.launchdarkly.sdk; + +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/sdk/UserAttribute.java b/src/main/java/com/launchdarkly/sdk/UserAttribute.java new file mode 100644 index 0000000..8dd78f1 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/UserAttribute.java @@ -0,0 +1,151 @@ +package com.launchdarkly.sdk; + +import com.google.gson.TypeAdapter; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +/** + * Represents a built-in or custom attribute name supported by {@link LDUser}. + *

+ * This abstraction helps to distinguish attribute names from other {@link String} values, and also + * improves efficiency in feature flag data structures and evaluations because built-in attributes + * always reuse the same instances. + *

+ * For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference + * guides on Setting user attributes + * and Targeting users. + * + * @since 5.0.0 + */ +@JsonAdapter(UserAttribute.UserAttributeTypeAdapter.class) +public final class UserAttribute { + /** + * Represents the user key attribute. + */ + public static final UserAttribute KEY = new UserAttribute("key", u -> u.key); + /** + * Represents the secondary key attribute. + */ + public static final UserAttribute SECONDARY_KEY = new UserAttribute("secondary", u -> u.secondary); + /** + * Represents the IP address attribute. + */ + public static final UserAttribute IP = new UserAttribute("ip", u -> u.ip); + /** + * Represents the user key attribute. + */ + public static final UserAttribute EMAIL = new UserAttribute("email", u -> u.email); + /** + * Represents the full name attribute. + */ + public static final UserAttribute NAME = new UserAttribute("name", u -> u.name); + /** + * Represents the avatar URL attribute. + */ + public static final UserAttribute AVATAR = new UserAttribute("avatar", u -> u.avatar); + /** + * Represents the first name attribute. + */ + public static final UserAttribute FIRST_NAME = new UserAttribute("firstName", u -> u.firstName); + /** + * Represents the last name attribute. + */ + public static final UserAttribute LAST_NAME = new UserAttribute("lastName", u -> u.lastName); + /** + * Represents the country attribute. + */ + public static final UserAttribute COUNTRY = new UserAttribute("country", u -> u.country); + /** + * Represents the anonymous attribute. + */ + public static final UserAttribute ANONYMOUS = new UserAttribute("anonymous", u -> u.anonymous); + + private static final Map BUILTINS; + static { + BUILTINS = new HashMap<>(); + for (UserAttribute a: new UserAttribute[] { KEY, SECONDARY_KEY, IP, EMAIL, NAME, AVATAR, FIRST_NAME, LAST_NAME, COUNTRY, ANONYMOUS }) { + BUILTINS.put(a.getName(), a); + } + } + + private final String name; + final Function builtInGetter; + + private UserAttribute(String name, Function builtInGetter) { + this.name = name; + this.builtInGetter = builtInGetter; + } + + /** + * Returns a UserAttribute instance for the specified attribute name. + *

+ * For built-in attributes, the same instances are always reused and {@link #isBuiltIn()} will + * return true. For custom attributes, a new instance is created and {@link #isBuiltIn()} will + * return false. + * + * @param name the attribute name + * @return a {@link UserAttribute} + */ + public static UserAttribute forName(String name) { + UserAttribute a = BUILTINS.get(name); + return a != null ? a : new UserAttribute(name, null); + } + + /** + * Returns the case-sensitive attribute name. + * + * @return the attribute name + */ + public String getName() { + return name; + } + + /** + * Returns true for a built-in attribute or false for a custom attribute. + * + * @return true if it is a built-in attribute + */ + public boolean isBuiltIn() { + return builtInGetter != null; + } + + @Override + public boolean equals(Object other) { + if (other instanceof UserAttribute) { + UserAttribute o = (UserAttribute)other; + if (isBuiltIn() || o.isBuiltIn()) { + return this == o; // faster comparison since built-in instances are interned + } + return name.equals(o.name); + } + return false; + } + + @Override + public int hashCode() { + return isBuiltIn() ? super.hashCode() : name.hashCode(); + } + + @Override + public String toString() { + return name; + } + + static final class UserAttributeTypeAdapter extends TypeAdapter{ + @Override + public UserAttribute read(JsonReader reader) throws IOException { + return UserAttribute.forName(reader.nextString()); + } + + @Override + public void write(JsonWriter writer, UserAttribute value) throws IOException { + writer.value(value.getName()); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/package-info.java b/src/main/java/com/launchdarkly/sdk/package-info.java new file mode 100644 index 0000000..9221655 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/package-info.java @@ -0,0 +1,4 @@ +/** + * Base namespace for LaunchDarkly Java-based SDKs, containing common types. + */ +package com.launchdarkly.sdk; diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java new file mode 100644 index 0000000..d319bbc --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java @@ -0,0 +1,89 @@ +package com.launchdarkly.sdk; + +import com.google.gson.Gson; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +@SuppressWarnings("javadoc") +public class EvaluationReasonTest { + private static final Gson gson = new Gson(); + + @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.Error r0 = EvaluationReason.error(errorKind); + assertEquals(errorKind, r0.getErrorKind()); + EvaluationReason.Error r1 = EvaluationReason.error(errorKind); + assertSame(r0, r1); + } + } + + private void assertJsonEqual(String expectedString, String actualString) { + LDValue expected = LDValue.parse(expectedString); + LDValue actual = LDValue.parse(actualString); + assertEquals(expected, actual); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/LDUserTest.java b/src/test/java/com/launchdarkly/sdk/LDUserTest.java new file mode 100644 index 0000000..83762d4 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/LDUserTest.java @@ -0,0 +1,270 @@ +package com.launchdarkly.sdk; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Function; + +import static com.launchdarkly.sdk.Helpers.transform; +import static com.launchdarkly.sdk.TestHelpers.setFromIterable; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +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 LDUserTest { + private static enum OptionalStringAttributes { + secondary(LDUser::getSecondary, LDUser.Builder::secondary, LDUser.Builder::privateSecondary), + ip(LDUser::getIp, LDUser.Builder::ip, LDUser.Builder::privateIp), + firstName(LDUser::getFirstName, LDUser.Builder::firstName, LDUser.Builder::privateFirstName), + lastName(LDUser::getLastName, LDUser.Builder::lastName, LDUser.Builder::privateLastName), + email(LDUser::getEmail, LDUser.Builder::email, LDUser.Builder::privateEmail), + name(LDUser::getName, LDUser.Builder::name, LDUser.Builder::privateName), + avatar(LDUser::getAvatar, LDUser.Builder::avatar, LDUser.Builder::privateAvatar), + country(LDUser::getCountry, LDUser.Builder::country, LDUser.Builder::privateCountry); + + final UserAttribute attribute; + final Function getter; + final BiFunction setter; + final BiFunction privateSetter; + + OptionalStringAttributes( + Function getter, + BiFunction setter, + BiFunction privateSetter + ) { + this.attribute = UserAttribute.forName(this.name()); + this.getter = getter; + this.setter = setter; + this.privateSetter = privateSetter; + } + }; + + @Test + public void simpleConstructorSetsKey() { + LDUser user = new LDUser("key"); + assertEquals("key", user.getKey()); + assertEquals(LDValue.of("key"), user.getAttribute(UserAttribute.KEY)); + for (OptionalStringAttributes a: OptionalStringAttributes.values()) { + assertNull(a.toString(), a.getter.apply(user)); + assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a.attribute)); + } + assertThat(user.isAnonymous(), is(false)); + assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); + assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); + assertThat(user.getCustomAttributes(), emptyIterable()); + assertThat(user.getPrivateAttributes(), emptyIterable()); + } + + @Test + public void builderSetsOptionalStringAttribute() { + for (OptionalStringAttributes a: OptionalStringAttributes.values()) { + String value = "value-of-" + a.name(); + LDUser.Builder builder = new LDUser.Builder("key"); + a.setter.apply(builder, value); + LDUser user = builder.build(); + for (OptionalStringAttributes a1: OptionalStringAttributes.values()) { + if (a1 == a) { + assertEquals(a.toString(), value, a1.getter.apply(user)); + assertEquals(a.toString(), LDValue.of(value), user.getAttribute(a1.attribute)); + } else { + assertNull(a.toString(), a1.getter.apply(user)); + assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a1.attribute)); + } + } + assertThat(user.isAnonymous(), is(false)); + assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); + assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); + assertThat(user.getCustomAttributes(), emptyIterable()); + assertThat(user.getPrivateAttributes(), emptyIterable()); + assertFalse(user.isAttributePrivate(a.attribute)); + } + } + + @Test + public void builderSetsPrivateOptionalStringAttribute() { + for (OptionalStringAttributes a: OptionalStringAttributes.values()) { + String value = "value-of-" + a.name(); + LDUser.Builder builder = new LDUser.Builder("key"); + a.privateSetter.apply(builder, value); + LDUser user = builder.build(); + for (OptionalStringAttributes a1: OptionalStringAttributes.values()) { + if (a1 == a) { + assertEquals(a.toString(), value, a1.getter.apply(user)); + assertEquals(a.toString(), LDValue.of(value), user.getAttribute(a1.attribute)); + } else { + assertNull(a.toString(), a1.getter.apply(user)); + assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a1.attribute)); + } + } + assertThat(user.isAnonymous(), is(false)); + assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); + assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); + assertThat(user.getCustomAttributes(), emptyIterable()); + assertThat(user.getPrivateAttributes(), contains(a.attribute)); + assertTrue(user.isAttributePrivate(a.attribute)); + } + } + + @Test + public void builderSetsCustomAttributes() { + LDValue boolValue = LDValue.of(true), + intValue = LDValue.of(2), + floatValue = LDValue.of(2.5), + stringValue = LDValue.of("x"), + jsonValue = LDValue.buildArray().build(); + LDUser user = new LDUser.Builder("key") + .custom("custom-bool", boolValue.booleanValue()) + .custom("custom-int", intValue.intValue()) + .custom("custom-float", floatValue.floatValue()) + .custom("custom-double", floatValue.doubleValue()) + .custom("custom-string", stringValue.stringValue()) + .custom("custom-json", jsonValue) + .build(); + List names = Arrays.asList("custom-bool", "custom-int", "custom-float", "custom-double", "custom-string", "custom-json"); + assertThat(user.getAttribute(UserAttribute.forName("custom-bool")), equalTo(boolValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-int")), equalTo(intValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-float")), equalTo(floatValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-double")), equalTo(floatValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-string")), equalTo(stringValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-json")), equalTo(jsonValue)); + assertThat(setFromIterable(user.getCustomAttributes()), + equalTo(setFromIterable(transform(names, UserAttribute::forName)))); + assertThat(user.getPrivateAttributes(), emptyIterable()); + for (String name: names) { + assertThat(name, user.isAttributePrivate(UserAttribute.forName(name)), is(false)); + } + } + + @Test + public void builderSetsPrivateCustomAttributes() { + LDValue boolValue = LDValue.of(true), + intValue = LDValue.of(2), + floatValue = LDValue.of(2.5), + stringValue = LDValue.of("x"), + jsonValue = LDValue.buildArray().build(); + LDUser user = new LDUser.Builder("key") + .privateCustom("custom-bool", boolValue.booleanValue()) + .privateCustom("custom-int", intValue.intValue()) + .privateCustom("custom-float", floatValue.floatValue()) + .privateCustom("custom-double", floatValue.doubleValue()) + .privateCustom("custom-string", stringValue.stringValue()) + .privateCustom("custom-json", jsonValue) + .build(); + List names = Arrays.asList("custom-bool", "custom-int", "custom-float", "custom-double", "custom-string", "custom-json"); + assertThat(user.getAttribute(UserAttribute.forName("custom-bool")), equalTo(boolValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-int")), equalTo(intValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-float")), equalTo(floatValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-double")), equalTo(floatValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-string")), equalTo(stringValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-json")), equalTo(jsonValue)); + assertThat(setFromIterable(user.getCustomAttributes()), + equalTo(setFromIterable(transform(names, UserAttribute::forName)))); + assertThat(setFromIterable(user.getPrivateAttributes()), equalTo(setFromIterable(user.getCustomAttributes()))); + for (String name: names) { + assertThat(name, user.isAttributePrivate(UserAttribute.forName(name)), is(true)); + } + } + + @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 canSetAnonymous() { + LDUser user1 = new LDUser.Builder("key").anonymous(true).build(); + assertThat(user1.isAnonymous(), is(true)); + assertThat(user1.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.of(true))); + + LDUser user2 = new LDUser.Builder("key").anonymous(false).build(); + assertThat(user2.isAnonymous(), is(false)); + assertThat(user2.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.of(false))); + } + + @Test + public void getAttributeGetsBuiltInAttributeEvenIfCustomAttrHasSameName() { + LDUser user = new LDUser.Builder("key") + .name("Jane") + .custom("name", "Joan") + .build(); + assertEquals(LDValue.of("Jane"), user.getAttribute(UserAttribute.forName("name"))); + } + + @Test + public void testMinimalJsonEncoding() { + LDUser user = new LDUser("userkey"); + String json = user.toJsonString(); + assertThat(json, equalTo("{\"key\":\"userkey\"}")); + } + + @Test + public void testDefaultJsonEncodingWithoutPrivateAttributes() { + LDUser user = new LDUser.Builder("userkey") + .secondary("s") + .ip("i") + .email("e") + .name("n") + .avatar("a") + .firstName("f") + .lastName("l") + .country("c") + .anonymous(true) + .custom("c1", "v1") + .build(); + LDValue json = LDValue.parse(user.toJsonString()); + assertThat(json, equalTo( + LDValue.buildObject() + .put("key", "userkey") + .put("secondary", "s") + .put("ip", "i") + .put("email", "e") + .put("name", "n") + .put("avatar", "a") + .put("firstName", "f") + .put("lastName", "l") + .put("country", "c") + .put("anonymous", true) + .put("custom", LDValue.buildObject().put("c1", "v1").build()) + .build() + )); + } + + @Test + public void testDefaultJsonEncodingWithPrivateAttributes() { + LDUser user = new LDUser.Builder("userkey") + .email("e") + .privateName("n") + .build(); + LDValue json = LDValue.parse(user.toJsonString()); + assertThat(json, equalTo( + LDValue.buildObject() + .put("key", "userkey") + .put("email", "e") + .put("name", "n") + .put("privateAttributeNames", LDValue.buildArray().add("name").build()) + .build() + )); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/LDValueTest.java b/src/test/java/com/launchdarkly/sdk/LDValueTest.java new file mode 100644 index 0000000..a569d69 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/LDValueTest.java @@ -0,0 +1,447 @@ +package com.launchdarkly.sdk; + +import com.google.gson.Gson; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.launchdarkly.sdk.TestHelpers.listFromIterable; +import static java.util.Collections.addAll; +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.assertTrue; +import static org.junit.Assert.fail; + +@SuppressWarnings("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(); + + @Test + public void canGetValueAsBoolean() { + assertEquals(LDValueType.BOOLEAN, aTrueBoolValue.getType()); + assertTrue(aTrueBoolValue.booleanValue()); + } + + @Test + public void nonBooleanValueAsBooleanIsFalse() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + aStringValue, + anIntValue, + aLongValue, + aFloatValue, + aDoubleValue, + anArrayValue, + anObjectValue, + }; + 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()); + } + + @Test + public void nonStringValueAsStringIsNull() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + aTrueBoolValue, + anIntValue, + aLongValue, + aFloatValue, + aDoubleValue, + anArrayValue, + anObjectValue + }; + 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, + aStringValue, + aNumericLookingStringValue, + anArrayValue, + anObjectValue + }; + 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 canGetSizeOfArray() { + assertEquals(1, anArrayValue.size()); + } + + @Test + public void arrayCanGetItemByIndex() { + assertEquals(LDValueType.ARRAY, anArrayValue.getType()); + assertEquals(LDValue.of(3), anArrayValue.get(0)); + assertEquals(LDValue.ofNull(), anArrayValue.get(-1)); + assertEquals(LDValue.ofNull(), anArrayValue.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); + } + List expected = new ArrayList<>(); + addAll(expected, a, b); + assertEquals(expected, values); + } + + @Test + public void arrayBuilderCanAddValuesAfterBuilding() { + ArrayBuilder builder = LDValue.buildArray(); + builder.add("a"); + LDValue firstArray = builder.build(); + assertEquals(1, firstArray.size()); + builder.add("b"); + LDValue secondArray = builder.build(); + assertEquals(2, secondArray.size()); + assertEquals(1, firstArray.size()); + } + + @Test + public void nonArrayValuesBehaveLikeEmptyArray() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + aTrueBoolValue, + 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()); + } + + @Test + public void objectCanGetValueByName() { + assertEquals(LDValueType.OBJECT, anObjectValue.getType()); + assertEquals(LDValue.of("x"), anObjectValue.get("1")); + assertEquals(LDValue.ofNull(), anObjectValue.get(null)); + assertEquals(LDValue.ofNull(), anObjectValue.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); + List expected = new ArrayList<>(); + addAll(expected, "1", "2"); + assertEquals(expected, 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); + List expected = new ArrayList<>(); + addAll(expected, "x", "y"); + assertEquals(expected, values); + } + + @Test + public void objectBuilderCanAddValuesAfterBuilding() { + ObjectBuilder builder = LDValue.buildObject(); + builder.put("a", 1); + LDValue firstObject = builder.build(); + assertEquals(1, firstObject.size()); + builder.put("b", 2); + LDValue secondObject = builder.build(); + assertEquals(2, secondObject.size()); + assertEquals(1, firstObject.size()); + } + + @Test + public void nonObjectValuesBehaveLikeEmptyObject() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + aTrueBoolValue, + 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 equalsUsesDeepEqualityForArrays() + { + LDValue a1 = LDValue.buildArray().add("a") + .add(LDValue.buildArray().add("b").add("c").build()) + .build(); + + LDValue a2 = LDValue.buildArray().add("a").build(); + assertValueAndHashNotEqual(a1, a2); + + LDValue a3 = LDValue.buildArray().add("a").add("b").add("c").build(); + assertValueAndHashNotEqual(a1, a3); + + LDValue a4 = LDValue.buildArray().add("a") + .add(LDValue.buildArray().add("b").add("x").build()) + .build(); + assertValueAndHashNotEqual(a1, a4); + } + + @Test + public void equalsUsesDeepEqualityForObjects() + { + LDValue o1 = LDValue.buildObject() + .put("a", "b") + .put("c", LDValue.buildObject().put("d", "e").build()) + .build(); + + LDValue o2 = LDValue.buildObject() + .put("a", "b") + .build(); + assertValueAndHashNotEqual(o1, o2); + + LDValue o3 = LDValue.buildObject() + .put("a", "b") + .put("c", LDValue.buildObject().put("d", "e").build()) + .put("f", "g") + .build(); + assertValueAndHashNotEqual(o1, o3); + + LDValue o4 = LDValue.buildObject() + .put("a", "b") + .put("c", LDValue.buildObject().put("d", "f").build()) + .build(); + assertValueAndHashNotEqual(o1, 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("false", LDValue.of(false).toJsonString()); + assertEquals(String.valueOf(someInt), anIntValue.toJsonString()); + assertEquals(String.valueOf(someLong), aLongValue.toJsonString()); + assertEquals(String.valueOf(someFloat), aFloatValue.toJsonString()); + assertEquals(String.valueOf(someDouble), aDoubleValue.toJsonString()); + assertEquals("\"hi\"", aStringValue.toJsonString()); + assertEquals("[3]", anArrayValue.toJsonString()); + assertEquals("{\"1\":\"x\"}", anObjectValue.toJsonString()); + } + + @Test + public void testDefaultGsonSerialization() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + aTrueBoolValue, + anIntValue, + aLongValue, + aFloatValue, + aDoubleValue, + aStringValue, + anArrayValue, + anObjectValue + }; + 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 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)); + List list = new ArrayList<>(); + for (T v: values) { + list.add(v); + } + assertEquals(arrayValue, converter.arrayFrom(list)); + assertEquals(list, listFromIterable(arrayValue.valuesAs(converter))); + + ObjectBuilder ob = LDValue.buildObject(); + int i = 0; + for (LDValue v: ldValues) { + ob.put(String.valueOf(++i), v); + } + LDValue objectValue = ob.build(); + Map map = new HashMap<>(); + i = 0; + for (T v: values) { + map.put(String.valueOf(++i), v); + } + assertEquals(objectValue, converter.objectFrom(map)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/TestHelpers.java b/src/test/java/com/launchdarkly/sdk/TestHelpers.java new file mode 100644 index 0000000..a73e81e --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/TestHelpers.java @@ -0,0 +1,20 @@ +package com.launchdarkly.sdk; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class TestHelpers { + public static List listFromIterable(Iterable it) { + List list = new ArrayList<>(); + it.forEach(list::add); + return list; + } + + public static Set setFromIterable(Iterable it) { + Set set = new HashSet<>(); + it.forEach(set::add); + return set; + } +} diff --git a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java new file mode 100644 index 0000000..04a2d5f --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java @@ -0,0 +1,70 @@ +package com.launchdarkly.sdk; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class UserAttributeTest { + @Test + public void keyAttribute() { + assertEquals("key", UserAttribute.KEY.getName()); + assertTrue(UserAttribute.KEY.isBuiltIn()); + } + + @Test + public void secondaryKeyAttribute() { + assertEquals("secondary", UserAttribute.SECONDARY_KEY.getName()); + assertTrue(UserAttribute.SECONDARY_KEY.isBuiltIn()); + } + + @Test + public void ipAttribute() { + assertEquals("ip", UserAttribute.IP.getName()); + assertTrue(UserAttribute.IP.isBuiltIn()); + } + + @Test + public void emailAttribute() { + assertEquals("email", UserAttribute.EMAIL.getName()); + assertTrue(UserAttribute.EMAIL.isBuiltIn()); + } + + @Test + public void nameAttribute() { + assertEquals("name", UserAttribute.NAME.getName()); + assertTrue(UserAttribute.NAME.isBuiltIn()); + } + + @Test + public void avatarAttribute() { + assertEquals("avatar", UserAttribute.AVATAR.getName()); + assertTrue(UserAttribute.AVATAR.isBuiltIn()); + } + + @Test + public void firstNameAttribute() { + assertEquals("firstName", UserAttribute.FIRST_NAME.getName()); + assertTrue(UserAttribute.FIRST_NAME.isBuiltIn()); + } + + @Test + public void lastNameAttribute() { + assertEquals("lastName", UserAttribute.LAST_NAME.getName()); + assertTrue(UserAttribute.LAST_NAME.isBuiltIn()); + } + + @Test + public void anonymousAttribute() { + assertEquals("anonymous", UserAttribute.ANONYMOUS.getName()); + assertTrue(UserAttribute.ANONYMOUS.isBuiltIn()); + } + + @Test + public void customAttribute() { + assertEquals("things", UserAttribute.forName("things").getName()); + assertFalse(UserAttribute.forName("things").isBuiltIn()); + } +} From 374e08e63ad10bc58e7064260ae3b7f015687f3c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Apr 2020 14:30:27 -0700 Subject: [PATCH 02/91] stick with Java 7 for Android compatibility (#2) --- CONTRIBUTING.md | 4 + README.md | 2 +- build.gradle | 4 +- .../java/com/launchdarkly/sdk/Function.java | 12 +++ .../java/com/launchdarkly/sdk/Helpers.java | 1 - .../java/com/launchdarkly/sdk/LDUser.java | 6 +- .../java/com/launchdarkly/sdk/LDValue.java | 7 +- .../com/launchdarkly/sdk/LDValueArray.java | 4 +- .../com/launchdarkly/sdk/LDValueObject.java | 5 +- .../com/launchdarkly/sdk/UserAttribute.java | 71 +++++++++++++++--- .../java/com/launchdarkly/sdk/BiFunction.java | 13 ++++ .../java/com/launchdarkly/sdk/LDUserTest.java | 75 +++++++++++++++---- .../com/launchdarkly/sdk/TestHelpers.java | 8 +- 13 files changed, 173 insertions(+), 39 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/Function.java create mode 100644 src/test/java/com/launchdarkly/sdk/BiFunction.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index afd1ad6..f6fe7a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,3 +39,7 @@ To build the project and run all unit tests: ``` ./gradlew test ``` + +### Note on Java version and Android support + +This project is limited to Java 7 because it is used in both the LaunchDarkly server-side Java SDK and the LaunchDarkly Android SDK. Android only supports Java 8 to a limited degree, depending on both the version of the Android developer tools and the Android API version. Since this is a small code base, we have decided to use Java 7 for it despite the minor inconveniences that this causes in terms of syntax. diff --git a/README.md b/README.md index ad8c04b..e1187d5 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This project contains Java classes and interfaces that are shared between the La ## Supported Java versions -This version of the library works with Java 8 and above. +This version of the library works with Java 7 and above. ## Contributing diff --git a/build.gradle b/build.gradle index be0ead2..09378ad 100644 --- a/build.gradle +++ b/build.gradle @@ -34,8 +34,8 @@ allprojects { group = 'com.launchdarkly' version = "${version}" archivesBaseName = "launchdarkly-java-sdk-common" - sourceCompatibility = 1.8 - targetCompatibility = 1.8 + sourceCompatibility = 1.7 + targetCompatibility = 1.7 } ext { diff --git a/src/main/java/com/launchdarkly/sdk/Function.java b/src/main/java/com/launchdarkly/sdk/Function.java new file mode 100644 index 0000000..0038f22 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/Function.java @@ -0,0 +1,12 @@ +package com.launchdarkly.sdk; + +/** + * Equivalent to {@code java.util.function.Function}, which we can't use because this package must + * run in Android without Java 8 support. + * + * @param input parameter type + * @param return type + */ +interface Function { + public B apply(A a); +} diff --git a/src/main/java/com/launchdarkly/sdk/Helpers.java b/src/main/java/com/launchdarkly/sdk/Helpers.java index 0882471..20a292c 100644 --- a/src/main/java/com/launchdarkly/sdk/Helpers.java +++ b/src/main/java/com/launchdarkly/sdk/Helpers.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk; import java.util.Iterator; -import java.util.function.Function; /** * Internal helper classes that serve the same purpose as Guava helpers. We do not use Guava in this diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index 1070379..017984b 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -2,6 +2,7 @@ import com.google.gson.Gson; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -9,7 +10,6 @@ import static com.launchdarkly.sdk.Helpers.hashFrom; import static com.launchdarkly.sdk.Helpers.objectsEqual; -import static java.util.Collections.emptyList; import static java.util.Collections.unmodifiableMap; import static java.util.Collections.unmodifiableSet; @@ -189,7 +189,7 @@ public LDValue getAttribute(UserAttribute attribute) { * @return the custom attribute names */ public Iterable getCustomAttributes() { - return custom == null ? emptyList() : custom.keySet(); + return custom == null ? Collections.emptyList() : custom.keySet(); } /** @@ -200,7 +200,7 @@ public Iterable getCustomAttributes() { * @return the names of private attributes for this user */ public Iterable getPrivateAttributes() { - return privateAttributeNames == null ? emptyList() : privateAttributeNames; + return privateAttributeNames == null ? Collections.emptyList() : privateAttributeNames; } /** diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index 170a450..e5e2e11 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -309,7 +309,12 @@ public Iterable values() { * @return an iterable of values of the specified type */ public Iterable valuesAs(final Converter converter) { - return transform(values(), converter::toType); + return transform(values(), new Function() { + @Override + public T apply(LDValue a) { + return converter.toType(a); + } + }); } /** diff --git a/src/main/java/com/launchdarkly/sdk/LDValueArray.java b/src/main/java/com/launchdarkly/sdk/LDValueArray.java index f61385a..3c84fc5 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValueArray.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueArray.java @@ -4,14 +4,14 @@ import com.google.gson.stream.JsonWriter; import java.io.IOException; +import java.util.Collections; import java.util.List; -import static java.util.Collections.emptyList; import static java.util.Collections.unmodifiableList; @JsonAdapter(LDValueTypeAdapter.class) final class LDValueArray extends LDValue { - private static final LDValueArray EMPTY = new LDValueArray(emptyList()); + private static final LDValueArray EMPTY = new LDValueArray(Collections.emptyList()); private final List list; // Note that this is not diff --git a/src/main/java/com/launchdarkly/sdk/LDValueObject.java b/src/main/java/com/launchdarkly/sdk/LDValueObject.java index 33d1d35..00e8cda 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValueObject.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueObject.java @@ -4,13 +4,12 @@ import com.google.gson.stream.JsonWriter; import java.io.IOException; +import java.util.Collections; import java.util.Map; -import static java.util.Collections.emptyMap; - @JsonAdapter(LDValueTypeAdapter.class) final class LDValueObject extends LDValue { - private static final LDValueObject EMPTY = new LDValueObject(emptyMap()); + private static final LDValueObject EMPTY = new LDValueObject(Collections.emptyMap()); private final Map map; static LDValueObject fromMap(Map map) { diff --git a/src/main/java/com/launchdarkly/sdk/UserAttribute.java b/src/main/java/com/launchdarkly/sdk/UserAttribute.java index 8dd78f1..0f63e7a 100644 --- a/src/main/java/com/launchdarkly/sdk/UserAttribute.java +++ b/src/main/java/com/launchdarkly/sdk/UserAttribute.java @@ -8,7 +8,6 @@ import java.io.IOException; import java.util.HashMap; import java.util.Map; -import java.util.function.Function; /** * Represents a built-in or custom attribute name supported by {@link LDUser}. @@ -28,43 +27,93 @@ public final class UserAttribute { /** * Represents the user key attribute. */ - public static final UserAttribute KEY = new UserAttribute("key", u -> u.key); + public static final UserAttribute KEY = new UserAttribute("key", new Function() { + public LDValue apply(LDUser u) { + return u.key; + } + }); + /** * Represents the secondary key attribute. */ - public static final UserAttribute SECONDARY_KEY = new UserAttribute("secondary", u -> u.secondary); + public static final UserAttribute SECONDARY_KEY = new UserAttribute("secondary", new Function() { + public LDValue apply(LDUser u) { + return u.secondary; + } + }); + /** * Represents the IP address attribute. */ - public static final UserAttribute IP = new UserAttribute("ip", u -> u.ip); + public static final UserAttribute IP = new UserAttribute("ip", new Function() { + public LDValue apply(LDUser u) { + return u.ip; + } + }); + /** * Represents the user key attribute. */ - public static final UserAttribute EMAIL = new UserAttribute("email", u -> u.email); + public static final UserAttribute EMAIL = new UserAttribute("email", new Function() { + public LDValue apply(LDUser u) { + return u.email; + } + }); + /** * Represents the full name attribute. */ - public static final UserAttribute NAME = new UserAttribute("name", u -> u.name); + public static final UserAttribute NAME = new UserAttribute("name", new Function() { + public LDValue apply(LDUser u) { + return u.name; + } + }); + /** * Represents the avatar URL attribute. */ - public static final UserAttribute AVATAR = new UserAttribute("avatar", u -> u.avatar); + public static final UserAttribute AVATAR = new UserAttribute("avatar", new Function() { + public LDValue apply(LDUser u) { + return u.avatar; + } + }); + /** * Represents the first name attribute. */ - public static final UserAttribute FIRST_NAME = new UserAttribute("firstName", u -> u.firstName); + public static final UserAttribute FIRST_NAME = new UserAttribute("firstName", new Function() { + public LDValue apply(LDUser u) { + return u.firstName; + } + }); + /** * Represents the last name attribute. */ - public static final UserAttribute LAST_NAME = new UserAttribute("lastName", u -> u.lastName); + public static final UserAttribute LAST_NAME = new UserAttribute("lastName", new Function() { + public LDValue apply(LDUser u) { + return u.lastName; + } + }); + /** * Represents the country attribute. */ - public static final UserAttribute COUNTRY = new UserAttribute("country", u -> u.country); + public static final UserAttribute COUNTRY = new UserAttribute("country", new Function() { + public LDValue apply(LDUser u) { + return u.country; + } + }); + /** * Represents the anonymous attribute. */ - public static final UserAttribute ANONYMOUS = new UserAttribute("anonymous", u -> u.anonymous); + public static final UserAttribute ANONYMOUS = new UserAttribute("anonymous", new Function() { + public LDValue apply(LDUser u) { + return u.anonymous; + } + }); + private static final Map BUILTINS; static { diff --git a/src/test/java/com/launchdarkly/sdk/BiFunction.java b/src/test/java/com/launchdarkly/sdk/BiFunction.java new file mode 100644 index 0000000..038c12d --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/BiFunction.java @@ -0,0 +1,13 @@ +package com.launchdarkly.sdk; + +/** + * Equivalent to {@code java.util.function.BiFunction}, which we can't use because this package must + * run in Android, where types from Java 8+ are not available. + * + * @param input parameter type + * @param second input parameter type + * @param return type + */ +interface BiFunction { + public C apply(A a, B b); +} diff --git a/src/test/java/com/launchdarkly/sdk/LDUserTest.java b/src/test/java/com/launchdarkly/sdk/LDUserTest.java index 83762d4..ab51fee 100644 --- a/src/test/java/com/launchdarkly/sdk/LDUserTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDUserTest.java @@ -4,8 +4,6 @@ import java.util.Arrays; import java.util.List; -import java.util.function.BiFunction; -import java.util.function.Function; import static com.launchdarkly.sdk.Helpers.transform; import static com.launchdarkly.sdk.TestHelpers.setFromIterable; @@ -22,21 +20,68 @@ @SuppressWarnings("javadoc") public class LDUserTest { private static enum OptionalStringAttributes { - secondary(LDUser::getSecondary, LDUser.Builder::secondary, LDUser.Builder::privateSecondary), - ip(LDUser::getIp, LDUser.Builder::ip, LDUser.Builder::privateIp), - firstName(LDUser::getFirstName, LDUser.Builder::firstName, LDUser.Builder::privateFirstName), - lastName(LDUser::getLastName, LDUser.Builder::lastName, LDUser.Builder::privateLastName), - email(LDUser::getEmail, LDUser.Builder::email, LDUser.Builder::privateEmail), - name(LDUser::getName, LDUser.Builder::name, LDUser.Builder::privateName), - avatar(LDUser::getAvatar, LDUser.Builder::avatar, LDUser.Builder::privateAvatar), - country(LDUser::getCountry, LDUser.Builder::country, LDUser.Builder::privateCountry); + secondary( + new Function() { public String apply(LDUser u) { return u.getSecondary(); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.secondary(s); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateSecondary(s); } }), + + ip( + new Function() { public String apply(LDUser u) { return u.getIp(); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.ip(s); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateIp(s); } }), + + firstName( + new Function() { public String apply(LDUser u) { return u.getFirstName(); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.firstName(s); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateFirstName(s); } }), + + lastName( + new Function() { public String apply(LDUser u) { return u.getLastName(); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.lastName(s); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateLastName(s); } }), + + email( + new Function() { public String apply(LDUser u) { return u.getEmail(); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.email(s); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateEmail(s); } }), + + name( + new Function() { public String apply(LDUser u) { return u.getName(); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.name(s); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateName(s); } }), + + avatar( + new Function() { public String apply(LDUser u) { return u.getAvatar(); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.avatar(s); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateAvatar(s); } }), + + country( + new Function() { public String apply(LDUser u) { return u.getCountry(); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.country(s); } }, + new BiFunction() + { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateCountry(s); } }); final UserAttribute attribute; final Function getter; final BiFunction setter; final BiFunction privateSetter; - OptionalStringAttributes( + private OptionalStringAttributes( Function getter, BiFunction setter, BiFunction privateSetter @@ -137,7 +182,9 @@ public void builderSetsCustomAttributes() { assertThat(user.getAttribute(UserAttribute.forName("custom-string")), equalTo(stringValue)); assertThat(user.getAttribute(UserAttribute.forName("custom-json")), equalTo(jsonValue)); assertThat(setFromIterable(user.getCustomAttributes()), - equalTo(setFromIterable(transform(names, UserAttribute::forName)))); + equalTo(setFromIterable(transform(names, new Function() { + public UserAttribute apply(String s) { return UserAttribute.forName(s); } + })))); assertThat(user.getPrivateAttributes(), emptyIterable()); for (String name: names) { assertThat(name, user.isAttributePrivate(UserAttribute.forName(name)), is(false)); @@ -167,7 +214,9 @@ public void builderSetsPrivateCustomAttributes() { assertThat(user.getAttribute(UserAttribute.forName("custom-string")), equalTo(stringValue)); assertThat(user.getAttribute(UserAttribute.forName("custom-json")), equalTo(jsonValue)); assertThat(setFromIterable(user.getCustomAttributes()), - equalTo(setFromIterable(transform(names, UserAttribute::forName)))); + equalTo(setFromIterable(transform(names, new Function() { + public UserAttribute apply(String s) { return UserAttribute.forName(s); } + })))); assertThat(setFromIterable(user.getPrivateAttributes()), equalTo(setFromIterable(user.getCustomAttributes()))); for (String name: names) { assertThat(name, user.isAttributePrivate(UserAttribute.forName(name)), is(true)); diff --git a/src/test/java/com/launchdarkly/sdk/TestHelpers.java b/src/test/java/com/launchdarkly/sdk/TestHelpers.java index a73e81e..18817b1 100644 --- a/src/test/java/com/launchdarkly/sdk/TestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/TestHelpers.java @@ -8,13 +8,17 @@ public class TestHelpers { public static List listFromIterable(Iterable it) { List list = new ArrayList<>(); - it.forEach(list::add); + for (T t: it) { + list.add(t); + } return list; } public static Set setFromIterable(Iterable it) { Set set = new HashSet<>(); - it.forEach(set::add); + for (T t: it) { + set.add(t); + } return set; } } From f1b9cb436a1691ea571c8ea022dedd8d2ab3d40f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 20 Apr 2020 12:38:47 -0700 Subject: [PATCH 03/91] add getters to EvaluationReason and hide its subclasses --- .../launchdarkly/sdk/EvaluationReason.java | 113 ++++++++++++++---- .../sdk/EvaluationReasonTest.java | 6 +- 2 files changed, 91 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java index 4f33997..43cdbfb 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -8,8 +8,9 @@ * This is returned within {@link EvaluationDetail} by the SDK's "variation detail" methods such as * {@code boolVariationDetail}. *

- * Note that this is an enum-like class hierarchy rather than an enum, because some of the - * possible reasons have their own properties. + * Note that while {@link EvaluationReason} has subclasses as an implementation detail, the subclasses + * are not public and may be removed in the future. Always use methods of the base class such as + * {@link #getKind()} or {@link #getRuleIndex()} to inspect the reason. * * @since 4.3.0 */ @@ -51,7 +52,6 @@ public static enum Kind { /** * Enumerated type defining the possible values of {@link EvaluationReason.Error#getErrorKind()}. - * @since 4.3.0 */ public static enum ErrorKind { /** @@ -95,13 +95,74 @@ public static enum ErrorKind { /** * 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 insta + */ + public Exception getException() { + return null; + } + + /** + * Returns a simple string representation of the reason. + *

+ * This is a convenience method for debugging and any other use cases where a human-readable string is + * helpful. The exact format of the string is subject to change; if you need to make programmatic + * decisions based on the reason properties, use other methods like {@link #getKind()}. + */ @Override public String toString() { return getKind().name(); @@ -116,7 +177,7 @@ protected EvaluationReason(Kind kind) * Returns an instance of {@link Off}. * @return a reason object */ - public static Off off() { + public static EvaluationReason off() { return Off.instance; } @@ -124,7 +185,7 @@ public static Off off() { * Returns an instance of {@link TargetMatch}. * @return a reason object */ - public static TargetMatch targetMatch() { + public static EvaluationReason targetMatch() { return TargetMatch.instance; } @@ -134,7 +195,7 @@ public static TargetMatch targetMatch() { * @param ruleId the rule identifier * @return a reason object */ - public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { + public static EvaluationReason ruleMatch(int ruleIndex, String ruleId) { return new RuleMatch(ruleIndex, ruleId); } @@ -143,7 +204,7 @@ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { * @param prerequisiteKey the flag key of the prerequisite that failed * @return a reason object */ - public static PrerequisiteFailed prerequisiteFailed(String prerequisiteKey) { + public static EvaluationReason prerequisiteFailed(String prerequisiteKey) { return new PrerequisiteFailed(prerequisiteKey); } @@ -151,7 +212,7 @@ public static PrerequisiteFailed prerequisiteFailed(String prerequisiteKey) { * Returns an instance of {@link Fallthrough}. * @return a reason object */ - public static Fallthrough fallthrough() { + public static EvaluationReason fallthrough() { return Fallthrough.instance; } @@ -160,7 +221,7 @@ public static Fallthrough fallthrough() { * @param errorKind describes the type of error * @return a reason object */ - public static Error error(ErrorKind errorKind) { + public static EvaluationReason error(ErrorKind errorKind) { switch (errorKind) { case CLIENT_NOT_READY: return ERROR_CLIENT_NOT_READY; case EXCEPTION: return ERROR_EXCEPTION; @@ -176,18 +237,18 @@ public static Error error(ErrorKind errorKind) { * 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) { + public static EvaluationReason 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 + *

+ * This subclass is package-private; application code should only reference {@link EvaluationReason}. */ - public static class Off extends EvaluationReason { + static final class Off extends EvaluationReason { private Off() { super(Kind.OFF); } @@ -198,9 +259,10 @@ private Off() { /** * Subclass of {@link EvaluationReason} that indicates that the user key was specifically targeted * for this flag. - * @since 4.3.0 + *

+ * This subclass is package-private; application code should only reference {@link EvaluationReason}. */ - public static class TargetMatch extends EvaluationReason { + static final class TargetMatch extends EvaluationReason { private TargetMatch() { super(Kind.TARGET_MATCH); @@ -211,9 +273,10 @@ private TargetMatch() /** * Subclass of {@link EvaluationReason} that indicates that the user matched one of the flag's rules. - * @since 4.3.0 + *

+ * This subclass is package-private; application code should only reference {@link EvaluationReason}. */ - public static class RuleMatch extends EvaluationReason { + static final class RuleMatch extends EvaluationReason { private final int ruleIndex; private final String ruleId; @@ -262,9 +325,10 @@ public String toString() { /** * Subclass of {@link EvaluationReason} that indicates that the flag was considered off because it * had at least one prerequisite flag that either was off or did not return the desired variation. - * @since 4.3.0 + *

+ * This subclass is package-private; application code should only reference {@link EvaluationReason}. */ - public static class PrerequisiteFailed extends EvaluationReason { + static final class PrerequisiteFailed extends EvaluationReason { private final String prerequisiteKey; private PrerequisiteFailed(String prerequisiteKey) { @@ -300,9 +364,10 @@ public String toString() { /** * Subclass of {@link EvaluationReason} that indicates that the flag was on but the user did not * match any targets or rules. - * @since 4.3.0 + *

+ * This subclass is package-private; application code should only reference {@link EvaluationReason}. */ - public static class Fallthrough extends EvaluationReason { + static final class Fallthrough extends EvaluationReason { private Fallthrough() { super(Kind.FALLTHROUGH); @@ -313,9 +378,10 @@ private Fallthrough() /** * Subclass of {@link EvaluationReason} that indicates that the flag could not be evaluated. - * @since 4.3.0 + *

+ * This subclass is package-private; application code should only reference {@link EvaluationReason}. */ - public static class Error extends EvaluationReason { + static final 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 @@ -342,7 +408,6 @@ public ErrorKind getErrorKind() { * This is only set if {@link #getErrorKind()} is {@link ErrorKind#EXCEPTION}. * * @return the exception instance - * @since 4.11.0 */ public Exception getException() { return exception; diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java index d319bbc..f2608d5 100644 --- a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java @@ -1,8 +1,6 @@ package com.launchdarkly.sdk; import com.google.gson.Gson; -import com.launchdarkly.sdk.EvaluationReason; -import com.launchdarkly.sdk.LDValue; import org.junit.Test; @@ -74,9 +72,9 @@ public void testErrorSerializationWithException() { @Test public void errorInstancesAreReused() { for (EvaluationReason.ErrorKind errorKind: EvaluationReason.ErrorKind.values()) { - EvaluationReason.Error r0 = EvaluationReason.error(errorKind); + EvaluationReason r0 = EvaluationReason.error(errorKind); assertEquals(errorKind, r0.getErrorKind()); - EvaluationReason.Error r1 = EvaluationReason.error(errorKind); + EvaluationReason r1 = EvaluationReason.error(errorKind); assertSame(r0, r1); } } From e86ced348832948c76eb58b30706915e4f9778d6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 20 Apr 2020 12:52:52 -0700 Subject: [PATCH 04/91] completely remove EvaluationReason subclasses --- .../launchdarkly/sdk/EvaluationReason.java | 301 +++++------------- .../sdk/EvaluationReasonTypeAdapter.java | 92 ++++++ 2 files changed, 173 insertions(+), 220 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java index 43cdbfb..afc01fd 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk; +import com.google.gson.annotations.JsonAdapter; + import java.util.Objects; /** @@ -14,8 +16,8 @@ * * @since 4.3.0 */ -public abstract class EvaluationReason { - +@JsonAdapter(EvaluationReasonTypeAdapter.class) +public final class EvaluationReason { /** * Enumerated type defining the possible values of {@link EvaluationReason#getKind()}. * @since 4.3.0 @@ -83,15 +85,41 @@ public static enum ErrorKind { 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); + // static instances to avoid repeatedly allocating reasons for the same parameters + private static final EvaluationReason OFF_INSTANCE = new EvaluationReason(Kind.OFF); + private static final EvaluationReason FALLTHROUGH_INSTANCE = new EvaluationReason(Kind.FALLTHROUGH); + private static final EvaluationReason TARGET_MATCH_INSTANCE = new EvaluationReason(Kind.TARGET_MATCH); + private static final EvaluationReason ERROR_CLIENT_NOT_READY = new EvaluationReason(ErrorKind.CLIENT_NOT_READY, null); + private static final EvaluationReason ERROR_FLAG_NOT_FOUND = new EvaluationReason(ErrorKind.FLAG_NOT_FOUND, null); + private static final EvaluationReason ERROR_MALFORMED_FLAG = new EvaluationReason(ErrorKind.MALFORMED_FLAG, null); + private static final EvaluationReason ERROR_USER_NOT_SPECIFIED = new EvaluationReason(ErrorKind.USER_NOT_SPECIFIED, null); + private static final EvaluationReason ERROR_WRONG_TYPE = new EvaluationReason(ErrorKind.WRONG_TYPE, null); + private static final EvaluationReason ERROR_EXCEPTION = new EvaluationReason(ErrorKind.EXCEPTION, null); private final Kind kind; + private final int ruleIndex; + private final String ruleId; + private final String prerequisiteKey; + private final ErrorKind errorKind; + private final Exception exception; + + private EvaluationReason(Kind kind, int ruleIndex, String ruleId, String prerequisiteKey, + ErrorKind errorKind, Exception exception) { + this.kind = kind; + this.ruleIndex = ruleIndex; + this.ruleId = ruleId; + this.prerequisiteKey = prerequisiteKey; + this.errorKind = errorKind; + this.exception = exception; + } + + private EvaluationReason(Kind kind) { + this(kind, -1, null, null, null, null); + } + + private EvaluationReason(ErrorKind errorKind, Exception exception) { + this(Kind.ERROR, -1, null, null, errorKind, exception); + } /** * Returns an enum indicating the general category of the reason. @@ -110,7 +138,7 @@ public Kind getKind() * @return the rule index or -1 */ public int getRuleIndex() { - return -1; + return ruleIndex; } /** @@ -122,7 +150,7 @@ public int getRuleIndex() { * @return the rule identifier or null */ public String getRuleId() { - return null; + return ruleId; } /** @@ -132,7 +160,7 @@ public String getRuleId() { * @return the prerequisite flag key or null */ public String getPrerequisiteKey() { - return null; + return prerequisiteKey; } /** @@ -142,7 +170,7 @@ public String getPrerequisiteKey() { * @return the error kind or null */ public ErrorKind getErrorKind() { - return null; + return errorKind; } /** @@ -153,7 +181,7 @@ public ErrorKind getErrorKind() { * @return the exception insta */ public Exception getException() { - return null; + return exception; } /** @@ -165,12 +193,32 @@ public Exception getException() { */ @Override public String toString() { - return getKind().name(); + switch (kind) { + case RULE_MATCH: + return kind + "(" + ruleIndex + (ruleId == null ? "" : ("," + ruleId)) + ")"; + case PREREQUISITE_FAILED: + return kind + "(" + prerequisiteKey + ")"; + case ERROR: + return kind + "(" + errorKind + (exception == null ? "" : ("," + exception)) + ")"; + default: + return getKind().name(); + } } - - protected EvaluationReason(Kind kind) - { - this.kind = kind; + + @Override + public boolean equals(Object other) { + if (other instanceof EvaluationReason) { + EvaluationReason o = (EvaluationReason)other; + return kind == o.kind && ruleIndex == o.ruleIndex && Objects.equals(ruleId, o.ruleId)&& + Objects.equals(prerequisiteKey, o.prerequisiteKey) && Objects.equals(errorKind, o.errorKind) && + Objects.equals(exception, o.exception); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(kind, ruleIndex, ruleId, prerequisiteKey, errorKind, exception); } /** @@ -178,7 +226,15 @@ protected EvaluationReason(Kind kind) * @return a reason object */ public static EvaluationReason off() { - return Off.instance; + return OFF_INSTANCE; + } + + /** + * Returns an instance of {@link Fallthrough}. + * @return a reason object + */ + public static EvaluationReason fallthrough() { + return FALLTHROUGH_INSTANCE; } /** @@ -186,7 +242,7 @@ public static EvaluationReason off() { * @return a reason object */ public static EvaluationReason targetMatch() { - return TargetMatch.instance; + return TARGET_MATCH_INSTANCE; } /** @@ -196,7 +252,7 @@ public static EvaluationReason targetMatch() { * @return a reason object */ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId) { - return new RuleMatch(ruleIndex, ruleId); + return new EvaluationReason(Kind.RULE_MATCH, ruleIndex, ruleId, null, null, null); } /** @@ -205,15 +261,7 @@ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId) { * @return a reason object */ public static EvaluationReason prerequisiteFailed(String prerequisiteKey) { - return new PrerequisiteFailed(prerequisiteKey); - } - - /** - * Returns an instance of {@link Fallthrough}. - * @return a reason object - */ - public static EvaluationReason fallthrough() { - return Fallthrough.instance; + return new EvaluationReason(Kind.PREREQUISITE_FAILED, -1, null, prerequisiteKey, null, null); } /** @@ -229,7 +277,7 @@ public static EvaluationReason error(ErrorKind errorKind) { 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); + default: return new EvaluationReason(errorKind, null); } } @@ -239,193 +287,6 @@ public static EvaluationReason error(ErrorKind errorKind) { * @return a reason object */ public static EvaluationReason 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. - *

- * This subclass is package-private; application code should only reference {@link EvaluationReason}. - */ - static final 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. - *

- * This subclass is package-private; application code should only reference {@link EvaluationReason}. - */ - static final 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. - *

- * This subclass is package-private; application code should only reference {@link EvaluationReason}. - */ - static final 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 - */ - 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 - */ - 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. - *

- * This subclass is package-private; application code should only reference {@link EvaluationReason}. - */ - static final class PrerequisiteFailed extends EvaluationReason { - private final String prerequisiteKey; - - private PrerequisiteFailed(String prerequisiteKey) { - super(Kind.PREREQUISITE_FAILED); - this.prerequisiteKey = prerequisiteKey; - } - - /** - * The key of the prerequisite flag that did not return the desired variation. - * @return the prerequisite flag key - */ - 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. - *

- * This subclass is package-private; application code should only reference {@link EvaluationReason}. - */ - static final 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. - *

- * This subclass is package-private; application code should only reference {@link EvaluationReason}. - */ - static final 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); - this.errorKind = errorKind; - this.exception = exception; - } - - /** - * An enumeration value indicating the general category of error. - * @return the error kind - */ - 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 - */ - 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)) + ")"; - } + return new EvaluationReason(ErrorKind.EXCEPTION, exception); } } diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java new file mode 100644 index 0000000..762ab54 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java @@ -0,0 +1,92 @@ +package com.launchdarkly.sdk; + +import com.google.gson.JsonParseException; +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 EvaluationReasonTypeAdapter extends TypeAdapter { + @Override + public EvaluationReason read(JsonReader reader) throws IOException { + EvaluationReason.Kind kind = null; + int ruleIndex = -1; + String ruleId = null; + String prereqKey = null; + EvaluationReason.ErrorKind errorKind = null; + + reader.beginObject(); + while (reader.peek() != JsonToken.END_OBJECT) { + String key = reader.nextName(); + switch (key) { + case "kind": + kind = Enum.valueOf(EvaluationReason.Kind.class, reader.nextString()); + break; + case "ruleIndex": + ruleIndex = reader.nextInt(); + break; + case "ruleId": + ruleId = reader.nextString(); + break; + case "prerequisiteKey": + prereqKey = reader.nextString(); + break; + case "errorKind": + errorKind = Enum.valueOf(EvaluationReason.ErrorKind.class, reader.nextString()); + break; + } + } + reader.endObject(); + + switch (kind) { + case OFF: + return EvaluationReason.off(); + case FALLTHROUGH: + return EvaluationReason.fallthrough(); + case TARGET_MATCH: + return EvaluationReason.targetMatch(); + case RULE_MATCH: + return EvaluationReason.ruleMatch(ruleIndex, ruleId); + case PREREQUISITE_FAILED: + return EvaluationReason.prerequisiteFailed(prereqKey); + case ERROR: + return EvaluationReason.error(errorKind); + } + throw new JsonParseException("EvaluationReason missing required property \"kind\""); + } + + @Override + public void write(JsonWriter writer, EvaluationReason reason) throws IOException { + writer.beginObject(); + writer.name("kind"); + writer.value(reason.getKind().name()); + + switch (reason.getKind()) { + case RULE_MATCH: + writer.name("ruleIndex"); + writer.value(reason.getRuleIndex()); + if (reason.getRuleId() != null) { + writer.name("ruleId"); + writer.value(reason.getRuleId()); + } + break; + case PREREQUISITE_FAILED: + writer.name("prerequisiteKey"); + writer.value(reason.getPrerequisiteKey()); + break; + case ERROR: + writer.name("errorKind"); + writer.value(reason.getErrorKind().name()); + // The exception field is not included in the JSON representation, since we do not want it to appear 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 including application code details). + break; + default: + break; + } + + writer.endObject(); + } +} From d440fd04d57e1e9cdd1498cc891b1665b94dad21 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 20 Apr 2020 14:43:32 -0700 Subject: [PATCH 05/91] add JSON helpers, better serialization logic, and Gson adapter --- .../launchdarkly/sdk/EvaluationDetail.java | 13 +- .../launchdarkly/sdk/EvaluationReason.java | 20 ++- .../java/com/launchdarkly/sdk/LDUser.java | 56 ++++---- .../launchdarkly/sdk/LDUserTypeAdapter.java | 129 ++++++++++++++++++ .../java/com/launchdarkly/sdk/LDValue.java | 50 ++++++- .../com/launchdarkly/sdk/UserAttribute.java | 2 +- .../sdk/json/GsonTypeAdapters.java | 98 +++++++++++++ .../sdk/json/JsonSerializable.java | 10 ++ .../sdk/json/JsonSerialization.java | 70 ++++++++++ .../sdk/json/SerializationException.java | 19 +++ .../launchdarkly/sdk/json/package-info.java | 4 + .../sdk/EvaluationReasonTest.java | 75 ++-------- .../java/com/launchdarkly/sdk/LDUserTest.java | 57 -------- .../com/launchdarkly/sdk/LDValueTest.java | 38 ------ ...EvaluationReasonJsonSerializationTest.java | 32 +++++ .../sdk/json/JsonTestHelpers.java | 43 ++++++ .../sdk/json/LDUserJsonSerializationTest.java | 61 +++++++++ .../json/LDValueJsonSerializationTest.java | 36 +++++ 18 files changed, 615 insertions(+), 198 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java create mode 100644 src/main/java/com/launchdarkly/sdk/json/GsonTypeAdapters.java create mode 100644 src/main/java/com/launchdarkly/sdk/json/JsonSerializable.java create mode 100644 src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java create mode 100644 src/main/java/com/launchdarkly/sdk/json/SerializationException.java create mode 100644 src/main/java/com/launchdarkly/sdk/json/package-info.java create mode 100644 src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java create mode 100644 src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java index aa3dcae..b97427a 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java @@ -1,16 +1,25 @@ package com.launchdarkly.sdk; +import com.launchdarkly.sdk.json.GsonTypeAdapters; +import com.launchdarkly.sdk.json.JsonSerializable; +import com.launchdarkly.sdk.json.JsonSerialization; + import static com.launchdarkly.sdk.Helpers.hashFrom; import static com.launchdarkly.sdk.Helpers.objectsEqual; /** * An object returned by the SDK's "variation detail" methods such as {@code boolVariationDetail}, * combining the result of a flag evaluation with an explanation of how it was calculated. + *

+ * {@link EvaluationReason} can be converted to and from JSON in one of two ways: + *

    + *
  1. With {@link JsonSerialization}. + *
  2. With Gson, if and only if you configure your Gson instance with {@link GsonTypeAdapters}. + *
* * @param the type of the wrapped value - * @since 4.3.0 */ -public class EvaluationDetail { +public final class EvaluationDetail implements JsonSerializable { private final EvaluationReason reason; private final Integer variationIndex; diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java index afc01fd..f2fbc5b 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -1,6 +1,9 @@ package com.launchdarkly.sdk; import com.google.gson.annotations.JsonAdapter; +import com.launchdarkly.sdk.json.GsonTypeAdapters; +import com.launchdarkly.sdk.json.JsonSerializable; +import com.launchdarkly.sdk.json.JsonSerialization; import java.util.Objects; @@ -13,14 +16,18 @@ * Note that while {@link EvaluationReason} has subclasses as an implementation detail, the subclasses * are not public and may be removed in the future. Always use methods of the base class such as * {@link #getKind()} or {@link #getRuleIndex()} to inspect the reason. - * - * @since 4.3.0 + *

+ * LaunchDarkly defines a standard JSON encoding for evaluation reasons, used in analytics events. + * {@link EvaluationReason} can be converted to and from JSON in one of two ways: + *

    + *
  1. With {@link JsonSerialization}. + *
  2. With Gson, if and only if you configure your Gson instance with {@link GsonTypeAdapters}. + *
*/ @JsonAdapter(EvaluationReasonTypeAdapter.class) -public final class EvaluationReason { +public final class EvaluationReason implements JsonSerializable { /** * Enumerated type defining the possible values of {@link EvaluationReason#getKind()}. - * @since 4.3.0 */ public static enum Kind { /** @@ -239,6 +246,7 @@ public static EvaluationReason fallthrough() { /** * Returns an instance of {@link TargetMatch}. + * * @return a reason object */ public static EvaluationReason targetMatch() { @@ -247,6 +255,7 @@ public static EvaluationReason targetMatch() { /** * Returns an instance of {@link RuleMatch}. + * * @param ruleIndex the rule index * @param ruleId the rule identifier * @return a reason object @@ -257,6 +266,7 @@ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId) { /** * Returns an instance of {@link PrerequisiteFailed}. + * * @param prerequisiteKey the flag key of the prerequisite that failed * @return a reason object */ @@ -266,6 +276,7 @@ public static EvaluationReason prerequisiteFailed(String prerequisiteKey) { /** * Returns an instance of {@link Error}. + * * @param errorKind describes the type of error * @return a reason object */ @@ -283,6 +294,7 @@ public static EvaluationReason error(ErrorKind errorKind) { /** * 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 */ diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index 017984b..bb15b3b 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -1,6 +1,9 @@ package com.launchdarkly.sdk; -import com.google.gson.Gson; +import com.google.gson.annotations.JsonAdapter; +import com.launchdarkly.sdk.json.GsonTypeAdapters; +import com.launchdarkly.sdk.json.JsonSerializable; +import com.launchdarkly.sdk.json.JsonSerialization; import java.util.Collections; import java.util.HashMap; @@ -23,15 +26,16 @@ * For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference * guides on
Setting user attributes * and Targeting users. - *

- * If you want to pass an LDUser object to the front end to be used with the JavaScript SDK, call {@link #toJsonString()} - * to get its JSON encoding. Do not try to pass an LDUser instance to a reflection-based encoder such as Gson; its - * internal structure does not correspond directly to the JSON encoding, and an external instance of Gson will not - * recognize the Gson annotations used inside the SDK. + *

+ * LaunchDarkly defines a standard JSON encoding for user objects, used by the JavaScript SDK and also in analytics + * events. {@link LDUser} can be converted to and from JSON in one of two ways: + *

    + *
  1. With {@link JsonSerialization}. + *
  2. With Gson, if and only if you configure your Gson instance with {@link GsonTypeAdapters}. + *
*/ -public class LDUser { - private static final Gson defaultGson = new Gson(); - +@JsonAdapter(LDUserTypeAdapter.class) +public class LDUser implements JsonSerializable { // 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. final LDValue key; @@ -212,22 +216,6 @@ public Iterable getPrivateAttributes() { public boolean isAttributePrivate(UserAttribute attribute) { return privateAttributeNames != null && privateAttributeNames.contains(attribute); } - - /** - * Converts the user data to its standard JSON representation. - *

- * This is the same format that the LaunchDarkly JavaScript browser SDK uses to represent users, so - * it is the simplest way to pass user data to front-end code. - *

- * Do not pass the {@link LDUser} object to a reflection-based JSON encoder such as Gson. Although the - * SDK uses Gson internally, it uses shading so that the Gson types are not exposed, so an external - * instance of Gson will not recognize the type adapters that provide the correct format. - * - * @return a JSON representation of the user - */ - public String toJsonString() { - return defaultGson.toJson(this); - } @Override public boolean equals(Object o) { @@ -255,6 +243,11 @@ public int hashCode() { return hashFrom(key, secondary, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); } + @Override + public String toString() { + return "LDUser(" + JsonSerialization.serialize(this) + ")"; + } + /** * A builder that helps construct {@link LDUser} objects. Builder * calls can be chained, enabling the following pattern: @@ -308,6 +301,17 @@ public Builder(LDUser user) { this.privateAttributes = user.privateAttributeNames == null ? null : new HashSet<>(user.privateAttributeNames); } + /** + * Changes the user's key. + * + * @param key the user key + * @return the builder + */ + public Builder key(String s) { + this.key = s; + return this; + } + /** * Sets the IP for a user. * @@ -659,7 +663,7 @@ public Builder privateCustom(String k, LDValue v) { return this; } - private void addPrivate(UserAttribute attribute) { + void addPrivate(UserAttribute attribute) { if (privateAttributes == null) { privateAttributes = new HashSet<>(); } diff --git a/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java new file mode 100644 index 0000000..1fd223c --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java @@ -0,0 +1,129 @@ +package com.launchdarkly.sdk; + +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 LDUserTypeAdapter extends TypeAdapter{ + static final LDUserTypeAdapter INSTANCE = new LDUserTypeAdapter(); + + @Override + public LDUser read(JsonReader reader) throws IOException { + LDUser.Builder builder = new LDUser.Builder((String)null); + reader.beginObject(); + while (reader.peek() != JsonToken.END_OBJECT) { + String key = reader.nextName(); + switch (key) { + case "key": + builder.key(reader.nextString()); + break; + case "secondary": + builder.secondary(reader.nextString()); + break; + case "ip": + builder.ip(reader.nextString()); + break; + case "email": + builder.email(reader.nextString()); + break; + case "name": + builder.name(reader.nextString()); + break; + case "avatar": + builder.avatar(reader.nextString()); + break; + case "firstName": + builder.firstName(reader.nextString()); + break; + case "lastName": + builder.lastName(reader.nextString()); + break; + case "country": + builder.country(reader.nextString()); + break; + case "anonymous": + builder.anonymous(reader.nextBoolean()); + break; + case "custom": + if (reader.peek() == JsonToken.NULL) { + reader.nextNull(); + } else { + reader.beginObject(); + while (reader.peek() != JsonToken.END_OBJECT) { + String customKey = reader.nextName(); + LDValue customValue = LDValueTypeAdapter.INSTANCE.read(reader); + builder.custom(customKey, customValue); + } + reader.endObject(); + } + break; + case "privateAttributeNames": + if (reader.peek() == JsonToken.NULL) { + reader.nextNull(); + } else { + reader.beginArray(); + while (reader.peek() != JsonToken.END_ARRAY) { + String name = reader.nextString(); + builder.addPrivate(UserAttribute.forName(name)); + } + reader.endArray(); + } + break; + default: + LDValueTypeAdapter.INSTANCE.read(reader); + } + } + reader.endObject(); + return builder.build(); + } + + @Override + public void write(JsonWriter writer, LDUser user) throws IOException { + // Currently, the field layout of LDUser does match the JSON representation, so Gson's default + // reflection mechanism would work, but we've implemented serialization manually here to avoid + // relying on that implementation detail and also to reduce the overhead of reflection. + // + // Note that this is not the serialization we use in analytics events; the SDK has a different + // custom serializer for that, in order to implement the private attribute redaction logic. + // The logic here is for serializing LDUser in the format that is used when you pass a user to + // the SDK as an *input*, i.e. if you are passing it to front-end JS code. + + writer.beginObject(); + for (UserAttribute attr: UserAttribute.BUILTINS.values()) { + LDValue value = user.getAttribute(attr); + if (value != null && !value.isNull()) { + writer.name(attr.getName()); + LDValueTypeAdapter.INSTANCE.write(writer, value); + } + } + boolean hasCustom = false; + for (UserAttribute attr: user.getCustomAttributes()) { + if (!hasCustom) { + hasCustom = true; + writer.name("custom"); + writer.beginObject(); + } + writer.name(attr.getName()); + LDValueTypeAdapter.INSTANCE.write(writer, user.getAttribute(attr)); + } + if (hasCustom) { + writer.endObject(); + } + boolean hasPrivate = false; + for (UserAttribute attr: user.getPrivateAttributes()) { + if (!hasPrivate) { + hasPrivate = true; + writer.name("privateAttributeNames"); + writer.beginArray(); + } + writer.value(attr.getName()); + } + if (hasPrivate) { + writer.endArray(); + } + writer.endObject(); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index e5e2e11..bd36823 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -3,6 +3,10 @@ import com.google.gson.Gson; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.json.GsonTypeAdapters; +import com.launchdarkly.sdk.json.JsonSerializable; +import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.json.SerializationException; import java.io.IOException; import java.util.Map; @@ -28,11 +32,16 @@ * from the API, so the SDK does not expose this dependency and cannot cause version conflicts in * applications that use Gson themselves. Second, Gson's array and object types are mutable, which can * cause concurrency risks. - * - * @since 4.8.0 + *

+ * {@link LDValue} can be converted to and from JSON in one of three ways: + *

    + *
  1. With {@link JsonSerialization}. + *
  2. With Gson, if and only if you configure your Gson instance with {@link GsonTypeAdapters}. + *
  3. With the {@link LDValue} methods {@link #toJsonString()} and {@link #parse(String)}. + *
*/ @JsonAdapter(LDValueTypeAdapter.class) -public abstract class LDValue { +public abstract class LDValue implements JsonSerializable { static final Gson gson = new Gson(); /** @@ -150,11 +159,26 @@ public static ObjectBuilder buildObject() { /** * Parses an LDValue from a JSON representation. + *

+ * This convenience method is equivalent to using {@link JsonSerialization#deserialize(String, Class)} + * with the {@code LDValue} class, except for two things: 1. you do not have to provide the class + * parameter; 2. parsing errors are thrown as an unchecked {@code RuntimeException}, rather than a + * checked {@link SerializationException}, making this method somewhat more convenient in cases such + * as test code where explicit error handling is less important. + * * @param json a JSON string * @return an LDValue */ public static LDValue parse(String json) { - return gson.fromJson(json, LDValue.class); + try { + return JsonSerialization.deserialize(json, LDValue.class); + } catch (SerializationException e) { + if (e.getCause() instanceof RuntimeException) { + throw (RuntimeException)e.getCause(); + } else { + throw new RuntimeException(e.getCause()); + } + } } /** @@ -341,6 +365,9 @@ public LDValue get(String name) { /** * Converts this value to its JSON serialization. + *

+ * This method is equivalent to passing the {@code LDValue} instance to + * {@link JsonSerialization#serialize(JsonSerializable)}. * * @return a JSON string */ @@ -354,11 +381,26 @@ static boolean isInteger(double value) { return value == (double)((int)value); } + /** + * Returns a string representation of this value. + *

+ * This method currently returns the same JSON serialization as {@link #toJsonString()}. However, + * like most {@code toString()} implementations, it is intended mainly for convenience in + * debugging or other use cases where the goal is simply to have a human-readable format; it is + * not guaranteed to always match {@link #toJsonString()} in the future. If you need to verify + * the value type or other properties programmatically, use the getter methods of {@code LDValue}. + */ @Override public String toString() { return toJsonString(); } + /** + * Returns true if the other object is an {@link LDValue} that is logically equal. + *

+ * This is a deep equality comparison: for JSON arrays each element is compared recursively, and + * for JSON objects all property names and values must be deeply equal regardless of ordering. + */ @Override public boolean equals(Object o) { if (o instanceof LDValue) { diff --git a/src/main/java/com/launchdarkly/sdk/UserAttribute.java b/src/main/java/com/launchdarkly/sdk/UserAttribute.java index 0f63e7a..ced9df8 100644 --- a/src/main/java/com/launchdarkly/sdk/UserAttribute.java +++ b/src/main/java/com/launchdarkly/sdk/UserAttribute.java @@ -115,7 +115,7 @@ public LDValue apply(LDUser u) { }); - private static final Map BUILTINS; + static final Map BUILTINS; static { BUILTINS = new HashMap<>(); for (UserAttribute a: new UserAttribute[] { KEY, SECONDARY_KEY, IP, EMAIL, NAME, AVATAR, FIRST_NAME, LAST_NAME, COUNTRY, ANONYMOUS }) { diff --git a/src/main/java/com/launchdarkly/sdk/json/GsonTypeAdapters.java b/src/main/java/com/launchdarkly/sdk/json/GsonTypeAdapters.java new file mode 100644 index 0000000..64dfed3 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/GsonTypeAdapters.java @@ -0,0 +1,98 @@ +package com.launchdarkly.sdk.json; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; + +import java.io.IOException; + +/** + * A helper class for interoperability with application code that uses Gson. + *

+ * While the LaunchDarkly Java-based SDKs have used Gson + * internally in the past, they may not always do so-- and even if they do, some SDK distributions may + * embed their own copy of Gson with modified (shaded) class names so that it does not conflict with + * any Gson instance elsewhere in the classpath. For both of those reasons, applications should not + * assume that {@code Gson.toGson()} and {@code Gson.fromGson()}-- or any other JSON framework that is + * based on reflection-- will work correctly for SDK classes, whose correct JSON representations do + * not necessarily correspond to their internal field layout. This class addresses that issue + * for applications that prefer to use Gson for everything rather than calling + * {@link JsonSerialization} for individual objects. + *

+ * An application that wishes to use Gson to serialize or deserialize classes from the SDK should + * configure its {@code Gson} instance as follows: + *


+ *     Gson gson = new GsonBuilder()
+ *         .registerTypeAdapterFactory(new com.launchdarkly.sdk.GsonTypeAdapters())
+ *         // any other GsonBuilder options go here
+ *         .create();
+ * 
+ *

+ * This causes Gson to use the correct JSON representation logic (the same that would be used by + * {@link JsonSerialization}) for any types that have the SDK's {@link JsonSerializable} marker + * interface, such as {@link LDUser} and {@link LDValue}, regardless of whether they are the + * top-level object being serialized or are contained in something else such as a collection. It + * does not affect Gson's behavior for any other classes. + *

+ * Note that some of the LaunchDarkly SDK distributions deliberately do not expose Gson as a + * dependency, so if you are using Gson in your application you will need to make sure you have + * defined your own dependency on it. Referencing {@link GsonTypeAdapters} will cause a runtime + * exception if Gson is not in the caller's classpath. + */ +public class GsonTypeAdapters implements TypeAdapterFactory { + // Implementation note: + // The main reason this class exists is the Java server-side SDK's issue with Gson interoperability + // due to the use of shading in the default jar artifact. If the Gson type references in this class + // were also shaded in the SDK jar, then this class would not work with an unshaded Gson instance, + // which would defeat the whole purpose. Therefore, the Java SDK build will need to have special- + // case handling for this class when it builds the jar, and embed the original class file instead + // of one that has had shading applied. By design, none of the other Gson-related classes in this + // project would need such special handling; in the Java server-side SDK jar, they would be meant + // to use the shaded copy of Gson. + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + if (JsonSerializable.class.isAssignableFrom(type.getRawType())) { + return new GsonTypeAdapter<>(gson, type); + } + return null; + } + + private static class GsonTypeAdapter extends TypeAdapter { + private final Gson gson; + private final Class objectClass; + + @SuppressWarnings("unchecked") + GsonTypeAdapter(Gson gson, TypeToken type) { + this.gson = gson; + this.objectClass = (Class)type.getRawType(); + } + + @Override + public void write(JsonWriter out, T value) throws IOException { + String json = JsonSerialization.serializeInternal(value); + out.jsonValue(json); + } + + @Override + public T read(JsonReader in) throws IOException { + // This implementation is inefficient because we can't assume our internal Gson instance can + // use this JsonReader directly; instead we have to read the next JSON value, convert it to a + // string, and then ask our JsonSerialization to parse it back from a string. + JsonElement jsonTree = gson.fromJson(in, JsonElement.class); + String jsonString = gson.toJson(jsonTree); + try { + return JsonSerialization.deserializeInternal(jsonString, objectClass); + } catch (SerializationException e) { + throw new JsonParseException(e.getCause()); + } + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerializable.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerializable.java new file mode 100644 index 0000000..2290ad7 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerializable.java @@ -0,0 +1,10 @@ +package com.launchdarkly.sdk.json; + +/** + * Marker interface for SDK classes that have a custom JSON serialization. + * + * @see JsonSerialization + * @see GsonTypeAdapters + */ +public interface JsonSerializable { +} diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java new file mode 100644 index 0000000..4ec19e9 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -0,0 +1,70 @@ +package com.launchdarkly.sdk.json; + +import com.google.gson.Gson; +import com.launchdarkly.sdk.LDValue; + +/** + * Helper methods for JSON serialization of SDK classes. + *

+ * While the LaunchDarkly Java-based SDKs have used Gson + * internally in the past, they may not always do so-- and even if they do, some SDK distributions may + * embed their own copy of Gson with modified (shaded) class names so that it does not conflict with + * any Gson instance elsewhere in the classpath. For both of those reasons, applications should not + * assume that {@code Gson.toGson()} and {@code Gson.fromGson()}-- or any other JSON framework that is + * based on reflection-- will work correctly for SDK classes, whose correct JSON representations do + * not necessarily correspond to their internal field layout. Instead, they should always use one of + * the following: + *

    + *
  1. The {@link JsonSerialization} methods. + *
  2. A Gson instance that has been configured with {@link GsonTypeAdapters}. + *
  3. For {@link LDValue}, you may also use the convenience methods {@link #toJsonString()} and + * {@link #parse(String)}. + *
+ */ +public abstract class JsonSerialization { + static final Gson gson = new Gson(); + + /** + * Converts an object to its JSON representation. + *

+ * This is only usable for classes that have the {@link JsonSerializable} marker interface, + * indicating that the SDK knows how to serialize them. + * + * @param class of the object being serialized + * @param instance the instance to serialize + * @return the object's JSON encoding as a string + */ + public static String serialize(T instance) { + return serializeInternal(instance); + } + + // We use this internally in situations where generic type checking isn't desirable + static String serializeInternal(Object instance) { + return gson.toJson(instance); + } + + /** + * Parses an object from its JSON representation. + *

+ * This is only usable for classes that have the {@link JsonSerializable} marker interface, + * indicating that the SDK knows how to serialize them. + * + * @param class of the object being deserialized + * @param json the object's JSON encoding as a string + * @param objectClass class of the object being deserialized + * @return the deserialized instance + * @throws SerializationException if the JSON encoding was invalid + */ + public static T deserialize(String json, Class objectClass) throws SerializationException { + return deserializeInternal(json, objectClass); + } + + // We use this internally in situations where generic type checking isn't desirable + static T deserializeInternal(String json, Class objectClass) throws SerializationException { + try { + return gson.fromJson(json, objectClass); + } catch (Exception e) { + throw new SerializationException(e); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/json/SerializationException.java b/src/main/java/com/launchdarkly/sdk/json/SerializationException.java new file mode 100644 index 0000000..909416d --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/SerializationException.java @@ -0,0 +1,19 @@ +package com.launchdarkly.sdk.json; + +/** + * General exception class for all errors in serializing or deserializing JSON. + *

+ * The SDK uses this class to avoid depending on exception types from the underlying JSON framework + * that it uses. The underlying exception can be inspected with the {@link Exception#getCause()} + * method, but application code should not rely on those details since they are subject to change. + */ +@SuppressWarnings("serial") +public class SerializationException extends Exception { + /** + * Creates an instance. + * @param cause the underlying exception + */ + public SerializationException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/json/package-info.java b/src/main/java/com/launchdarkly/sdk/json/package-info.java new file mode 100644 index 0000000..aa49ddd --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/package-info.java @@ -0,0 +1,4 @@ +/** + * Helper classes and methods for interoperability with JSON. + */ +package com.launchdarkly.sdk.json; diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java index f2608d5..b5b2ab6 100644 --- a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java @@ -1,72 +1,21 @@ package com.launchdarkly.sdk; -import com.google.gson.Gson; - import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; -@SuppressWarnings("javadoc") public class EvaluationReasonTest { - private static final Gson gson = new Gson(); - - @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()); + public void simpleStringRepresentations() { + assertEquals("OFF", EvaluationReason.off().toString()); + assertEquals("FALLTHROUGH", EvaluationReason.fallthrough().toString()); + assertEquals("TARGET_MATCH", EvaluationReason.targetMatch().toString()); + assertEquals("RULE_MATCH(1,id)", EvaluationReason.ruleMatch(1, "id").toString()); + assertEquals("PREREQUISITE_FAILED(key)", EvaluationReason.prerequisiteFailed("key").toString()); + assertEquals("ERROR(FLAG_NOT_FOUND)", EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND).toString()); + assertEquals("ERROR(EXCEPTION,java.lang.Exception: something happened)", + EvaluationReason.exception(new Exception("something happened")).toString()); } @Test @@ -78,10 +27,4 @@ public void errorInstancesAreReused() { assertSame(r0, r1); } } - - private void assertJsonEqual(String expectedString, String actualString) { - LDValue expected = LDValue.parse(expectedString); - LDValue actual = LDValue.parse(actualString); - assertEquals(expected, actual); - } } diff --git a/src/test/java/com/launchdarkly/sdk/LDUserTest.java b/src/test/java/com/launchdarkly/sdk/LDUserTest.java index ab51fee..9701ec4 100644 --- a/src/test/java/com/launchdarkly/sdk/LDUserTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDUserTest.java @@ -17,7 +17,6 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -@SuppressWarnings("javadoc") public class LDUserTest { private static enum OptionalStringAttributes { secondary( @@ -260,60 +259,4 @@ public void getAttributeGetsBuiltInAttributeEvenIfCustomAttrHasSameName() { .build(); assertEquals(LDValue.of("Jane"), user.getAttribute(UserAttribute.forName("name"))); } - - @Test - public void testMinimalJsonEncoding() { - LDUser user = new LDUser("userkey"); - String json = user.toJsonString(); - assertThat(json, equalTo("{\"key\":\"userkey\"}")); - } - - @Test - public void testDefaultJsonEncodingWithoutPrivateAttributes() { - LDUser user = new LDUser.Builder("userkey") - .secondary("s") - .ip("i") - .email("e") - .name("n") - .avatar("a") - .firstName("f") - .lastName("l") - .country("c") - .anonymous(true) - .custom("c1", "v1") - .build(); - LDValue json = LDValue.parse(user.toJsonString()); - assertThat(json, equalTo( - LDValue.buildObject() - .put("key", "userkey") - .put("secondary", "s") - .put("ip", "i") - .put("email", "e") - .put("name", "n") - .put("avatar", "a") - .put("firstName", "f") - .put("lastName", "l") - .put("country", "c") - .put("anonymous", true) - .put("custom", LDValue.buildObject().put("c1", "v1").build()) - .build() - )); - } - - @Test - public void testDefaultJsonEncodingWithPrivateAttributes() { - LDUser user = new LDUser.Builder("userkey") - .email("e") - .privateName("n") - .build(); - LDValue json = LDValue.parse(user.toJsonString()); - assertThat(json, equalTo( - LDValue.buildObject() - .put("key", "userkey") - .put("email", "e") - .put("name", "n") - .put("privateAttributeNames", LDValue.buildArray().add("name").build()) - .build() - )); - } } diff --git a/src/test/java/com/launchdarkly/sdk/LDValueTest.java b/src/test/java/com/launchdarkly/sdk/LDValueTest.java index a569d69..db8c609 100644 --- a/src/test/java/com/launchdarkly/sdk/LDValueTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueTest.java @@ -1,7 +1,5 @@ package com.launchdarkly.sdk; -import com.google.gson.Gson; - import org.junit.Test; import java.util.ArrayList; @@ -18,10 +16,7 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -@SuppressWarnings("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; @@ -374,39 +369,6 @@ public void canUseDoubleTypeForNumberGreaterThanMaxFloat() { assertEquals(n, LDValue.Convert.Double.fromType(n).doubleValue(), 0); } - @Test - public void testToJsonString() { - assertEquals("null", LDValue.ofNull().toJsonString()); - assertEquals("true", aTrueBoolValue.toJsonString()); - assertEquals("false", LDValue.of(false).toJsonString()); - assertEquals(String.valueOf(someInt), anIntValue.toJsonString()); - assertEquals(String.valueOf(someLong), aLongValue.toJsonString()); - assertEquals(String.valueOf(someFloat), aFloatValue.toJsonString()); - assertEquals(String.valueOf(someDouble), aDoubleValue.toJsonString()); - assertEquals("\"hi\"", aStringValue.toJsonString()); - assertEquals("[3]", anArrayValue.toJsonString()); - assertEquals("{\"1\":\"x\"}", anObjectValue.toJsonString()); - } - - @Test - public void testDefaultGsonSerialization() { - LDValue[] values = new LDValue[] { - LDValue.ofNull(), - aTrueBoolValue, - anIntValue, - aLongValue, - aFloatValue, - aDoubleValue, - aStringValue, - anArrayValue, - anObjectValue - }; - 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 testTypeConversions() { testTypeConversion(LDValue.Convert.Boolean, new Boolean[] { true, false }, LDValue.of(true), LDValue.of(false)); diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java new file mode 100644 index 0000000..583f08c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java @@ -0,0 +1,32 @@ +package com.launchdarkly.sdk.json; + +import com.launchdarkly.sdk.EvaluationReason; + +import org.junit.Test; + +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; + +public class EvaluationReasonJsonSerializationTest { + @Test + public void reasonJsonSerializations() throws Exception { + verifySerializeAndDeserialize(EvaluationReason.off(), "{\"kind\":\"OFF\"}"); + verifySerializeAndDeserialize(EvaluationReason.fallthrough(), "{\"kind\":\"FALLTHROUGH\"}"); + verifySerializeAndDeserialize(EvaluationReason.targetMatch(), "{\"kind\":\"TARGET_MATCH\"}"); + verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, "id"), + "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\"}"); + verifySerializeAndDeserialize(EvaluationReason.prerequisiteFailed("key"), + "{\"kind\":\"PREREQUISITE_FAILED\",\"prerequisiteKey\":\"key\"}"); + verifySerializeAndDeserialize(EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND), + "{\"kind\":\"ERROR\",\"errorKind\":\"FLAG_NOT_FOUND\"}"); + } + + @Test + public void errorSerializationWithException() throws Exception { + // 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 expectedJsonString = "{\"kind\":\"ERROR\",\"errorKind\":\"EXCEPTION\"}"; + verifySerialize(reason, expectedJsonString); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java new file mode 100644 index 0000000..ac57491 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java @@ -0,0 +1,43 @@ +package com.launchdarkly.sdk.json; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; + +import static org.junit.Assert.assertEquals; + +public abstract class JsonTestHelpers { + // Note that when we verify the behavior of Gson with GsonTypeAdapters in this project's unit tests, + // that is not an adequate test for whether the adapters will work in the Java SDK where there is + // the additional issue of Gson types being shaded. The Java SDK project must do its own basic tests + // of Gson interoperability using the shaded SDK jar. But the tests in this project still prove that + // the adapters work correctly if Gson actually uses them. + + public static void verifySerializeAndDeserialize(T instance, String expectedJsonString) throws Exception { + verifySerialize(instance, expectedJsonString); + verifyDeserialize(instance, expectedJsonString); + } + + public static void verifySerialize(T instance, String expectedJsonString) throws Exception { + // All subclasses of Gson's JsonElement implement deep equality for equals(). So does our own LDValue, + // but since some of our tests are testing LDValue itself, we can't assume that its behavior is correct. + assertEquals(parseElement(expectedJsonString), parseElement(JsonSerialization.serialize(instance))); + + Gson gson = new GsonBuilder().registerTypeAdapterFactory(new GsonTypeAdapters()).create(); + assertEquals(parseElement(expectedJsonString), parseElement(gson.toJson(instance))); + } + + @SuppressWarnings("unchecked") + public static void verifyDeserialize(T instance, String expectedJsonString) throws Exception { + T instance1 = JsonSerialization.deserialize(expectedJsonString, (Class)instance.getClass()); + assertEquals(instance, instance1); + + Gson gson = new GsonBuilder().registerTypeAdapterFactory(new GsonTypeAdapters()).create(); + T instance2 = gson.fromJson(expectedJsonString, (Class)instance.getClass()); + assertEquals(instance, instance2); + } + + static JsonElement parseElement(String jsonString) { + return JsonSerialization.gson.fromJson(jsonString, JsonElement.class); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java new file mode 100644 index 0000000..a396850 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java @@ -0,0 +1,61 @@ +package com.launchdarkly.sdk.json; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; + +import org.junit.Test; + +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; + +public class LDUserJsonSerializationTest { + @Test + public void minimalJsonEncoding() throws Exception { + LDUser user = new LDUser("userkey"); + verifySerializeAndDeserialize(user, "{\"key\":\"userkey\"}"); + } + + @Test + public void defaultJsonEncodingWithoutPrivateAttributes() throws Exception { + LDUser user = new LDUser.Builder("userkey") + .secondary("s") + .ip("i") + .email("e") + .name("n") + .avatar("a") + .firstName("f") + .lastName("l") + .country("c") + .anonymous(true) + .custom("c1", "v1") + .build(); + LDValue expectedJson = LDValue.buildObject() + .put("key", "userkey") + .put("secondary", "s") + .put("ip", "i") + .put("email", "e") + .put("name", "n") + .put("avatar", "a") + .put("firstName", "f") + .put("lastName", "l") + .put("country", "c") + .put("anonymous", true) + .put("custom", LDValue.buildObject().put("c1", "v1").build()) + .build(); + verifySerializeAndDeserialize(user, expectedJson.toJsonString()); + } + + @Test + public void defaultJsonEncodingWithPrivateAttributes() throws Exception { + LDUser user = new LDUser.Builder("userkey") + .email("e") + .privateName("n") + .build(); + LDValue expectedJson = LDValue.buildObject() + .put("key", "userkey") + .put("email", "e") + .put("name", "n") + .put("privateAttributeNames", LDValue.buildArray().add("name").build()) + .build(); + verifySerializeAndDeserialize(user, expectedJson.toJsonString()); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java new file mode 100644 index 0000000..e7a2fe1 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java @@ -0,0 +1,36 @@ +package com.launchdarkly.sdk.json; + +import com.launchdarkly.sdk.LDValue; + +import org.junit.Test; + +import static com.launchdarkly.sdk.json.JsonTestHelpers.parseElement; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; +import static org.junit.Assert.assertEquals; + +public class LDValueJsonSerializationTest { + @Test + public void jsonEncodingForNull() throws Exception { + verifySerialize(LDValue.ofNull(), "null"); + } + + @Test + public void jsonEncodingForNonNullValues() throws Exception { + verifyValueSerialization(LDValue.of(true), "true"); + verifyValueSerialization(LDValue.of(false), "false"); + verifyValueSerialization(LDValue.of("x"), "\"x\""); + verifyValueSerialization(LDValue.of("say \"hello\""), "\"say \\\"hello\\\"\""); + verifyValueSerialization(LDValue.of(2), "2"); + verifyValueSerialization(LDValue.of(2.5f), "2.5"); + verifyValueSerialization(LDValue.of(2.5d), "2.5"); + verifyValueSerialization(LDValue.buildArray().add(2).add("x").build(), "[2,\"x\"]"); + verifyValueSerialization(LDValue.buildObject().put("x", 2).build(), "{\"x\":2}"); + } + + private static void verifyValueSerialization(LDValue value, String expectedJsonString) throws Exception { + verifySerializeAndDeserialize(value, expectedJsonString); + assertEquals(parseElement(expectedJsonString), parseElement(value.toJsonString())); + assertEquals(value, LDValue.parse(expectedJsonString)); + } +} From 04eaf1a357ac132016ab849a47a245dadec51a7c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 21 Apr 2020 11:42:11 -0700 Subject: [PATCH 06/91] javadoc fixes --- .../launchdarkly/sdk/EvaluationReason.java | 30 ++++++++++++------- .../java/com/launchdarkly/sdk/LDUser.java | 2 +- .../sdk/json/JsonSerialization.java | 4 +-- .../sdk/EvaluationReasonTest.java | 1 + .../java/com/launchdarkly/sdk/LDUserTest.java | 1 + .../com/launchdarkly/sdk/LDValueTest.java | 1 + .../com/launchdarkly/sdk/TestHelpers.java | 1 + ...EvaluationReasonJsonSerializationTest.java | 1 + .../sdk/json/JsonTestHelpers.java | 1 + .../sdk/json/LDUserJsonSerializationTest.java | 1 + .../json/LDValueJsonSerializationTest.java | 1 + 11 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java index f2fbc5b..811ca31 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -60,7 +60,7 @@ public static enum Kind { } /** - * Enumerated type defining the possible values of {@link EvaluationReason.Error#getErrorKind()}. + * Enumerated type defining the possible values of {@link #getErrorKind()}. */ public static enum ErrorKind { /** @@ -87,7 +87,7 @@ public static enum ErrorKind { 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()}. + * in this case, and the exception should be available via {@link #getException()}. */ EXCEPTION } @@ -183,9 +183,12 @@ public ErrorKind getErrorKind() { /** * 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}. + * Otherwise {@code null}. + *

+ * Note that the exception will not be included in the JSON serialization of the reason when it + * appears in analytics events; it is only provided informationally for use by application code. * - * @return the exception insta + * @return the exception instance */ public Exception getException() { return exception; @@ -229,7 +232,8 @@ public int hashCode() { } /** - * Returns an instance of {@link Off}. + * Returns an instance whose {@code kind} is {@link Kind#OFF}. + * * @return a reason object */ public static EvaluationReason off() { @@ -237,7 +241,8 @@ public static EvaluationReason off() { } /** - * Returns an instance of {@link Fallthrough}. + * Returns an instance whose {@code kind} is {@link Kind#FALLTHROUGH}. + * * @return a reason object */ public static EvaluationReason fallthrough() { @@ -245,7 +250,7 @@ public static EvaluationReason fallthrough() { } /** - * Returns an instance of {@link TargetMatch}. + * Returns an instance whose {@code kind} is {@link Kind#TARGET_MATCH}. * * @return a reason object */ @@ -254,7 +259,7 @@ public static EvaluationReason targetMatch() { } /** - * Returns an instance of {@link RuleMatch}. + * Returns an instance whose {@code kind} is {@link Kind#RULE_MATCH}. * * @param ruleIndex the rule index * @param ruleId the rule identifier @@ -265,7 +270,7 @@ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId) { } /** - * Returns an instance of {@link PrerequisiteFailed}. + * Returns an instance whose {@code kind} is {@link Kind#PREREQUISITE_FAILED}. * * @param prerequisiteKey the flag key of the prerequisite that failed * @return a reason object @@ -275,7 +280,7 @@ public static EvaluationReason prerequisiteFailed(String prerequisiteKey) { } /** - * Returns an instance of {@link Error}. + * Returns an instance whose {@code kind} is {@link Kind#ERROR}. * * @param errorKind describes the type of error * @return a reason object @@ -293,7 +298,10 @@ public static EvaluationReason error(ErrorKind errorKind) { } /** - * Returns an instance of {@link Error} with the kind {@link ErrorKind#EXCEPTION} and an exception instance. + * Returns an instance whose {@code kind} is {@link Kind#ERROR}, with an exception instance. + *

+ * Note that the exception will not be included in the JSON serialization of the reason when it + * appears in analytics events; it is only provided informationally for use by application code. * * @param exception the exception that caused the error * @return a reason object diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index bb15b3b..caf908b 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -304,7 +304,7 @@ public Builder(LDUser user) { /** * Changes the user's key. * - * @param key the user key + * @param s the user key * @return the builder */ public Builder key(String s) { diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index 4ec19e9..8c10854 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -17,8 +17,8 @@ *

    *
  1. The {@link JsonSerialization} methods. *
  2. A Gson instance that has been configured with {@link GsonTypeAdapters}. - *
  3. For {@link LDValue}, you may also use the convenience methods {@link #toJsonString()} and - * {@link #parse(String)}. + *
  4. For {@link LDValue}, you may also use the convenience methods {@link LDValue#toJsonString()} and + * {@link LDValue#parse(String)}. *
*/ public abstract class JsonSerialization { diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java index b5b2ab6..fc785b7 100644 --- a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java @@ -5,6 +5,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; +@SuppressWarnings("javadoc") public class EvaluationReasonTest { @Test public void simpleStringRepresentations() { diff --git a/src/test/java/com/launchdarkly/sdk/LDUserTest.java b/src/test/java/com/launchdarkly/sdk/LDUserTest.java index 9701ec4..ef9e0b0 100644 --- a/src/test/java/com/launchdarkly/sdk/LDUserTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDUserTest.java @@ -17,6 +17,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +@SuppressWarnings("javadoc") public class LDUserTest { private static enum OptionalStringAttributes { secondary( diff --git a/src/test/java/com/launchdarkly/sdk/LDValueTest.java b/src/test/java/com/launchdarkly/sdk/LDValueTest.java index db8c609..ba2d0d4 100644 --- a/src/test/java/com/launchdarkly/sdk/LDValueTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueTest.java @@ -16,6 +16,7 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +@SuppressWarnings("javadoc") public class LDValueTest { private static final int someInt = 3; private static final long someLong = 3; diff --git a/src/test/java/com/launchdarkly/sdk/TestHelpers.java b/src/test/java/com/launchdarkly/sdk/TestHelpers.java index 18817b1..cd7bc22 100644 --- a/src/test/java/com/launchdarkly/sdk/TestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/TestHelpers.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Set; +@SuppressWarnings("javadoc") public class TestHelpers { public static List listFromIterable(Iterable it) { List list = new ArrayList<>(); diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java index 583f08c..f907e4f 100644 --- a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java @@ -7,6 +7,7 @@ import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; +@SuppressWarnings("javadoc") public class EvaluationReasonJsonSerializationTest { @Test public void reasonJsonSerializations() throws Exception { diff --git a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java index ac57491..867f269 100644 --- a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java @@ -6,6 +6,7 @@ import static org.junit.Assert.assertEquals; +@SuppressWarnings("javadoc") public abstract class JsonTestHelpers { // Note that when we verify the behavior of Gson with GsonTypeAdapters in this project's unit tests, // that is not an adequate test for whether the adapters will work in the Java SDK where there is diff --git a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java index a396850..7dd27d1 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java @@ -7,6 +7,7 @@ import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; +@SuppressWarnings("javadoc") public class LDUserJsonSerializationTest { @Test public void minimalJsonEncoding() throws Exception { diff --git a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java index e7a2fe1..8e44bd7 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java @@ -9,6 +9,7 @@ import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; import static org.junit.Assert.assertEquals; +@SuppressWarnings("javadoc") public class LDValueJsonSerializationTest { @Test public void jsonEncodingForNull() throws Exception { From 433a1e722484e07b258ab0068691c1f1482bbdeb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 21 Apr 2020 15:34:50 -0700 Subject: [PATCH 07/91] remove @since tags, misc doc fixes, add note about changelogging --- CONTRIBUTING.md | 9 +++++ .../com/launchdarkly/sdk/ArrayBuilder.java | 2 - .../launchdarkly/sdk/EvaluationDetail.java | 2 - .../launchdarkly/sdk/EvaluationReason.java | 38 ++++++++++++------- .../java/com/launchdarkly/sdk/LDUser.java | 2 - .../java/com/launchdarkly/sdk/LDValue.java | 5 --- .../com/launchdarkly/sdk/LDValueType.java | 2 - .../com/launchdarkly/sdk/ObjectBuilder.java | 2 - .../com/launchdarkly/sdk/UserAttribute.java | 2 - .../com/launchdarkly/sdk/TestHelpers.java | 1 + 10 files changed, 34 insertions(+), 31 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6fe7a9..082a7b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,15 @@ The LaunchDarkly SDK team monitors the [issue tracker](https://github.com/launch We encourage pull requests and other contributions from the community. Before submitting pull requests, ensure that all temporary or unintended code is removed. Don't worry about adding reviewers to the pull request; the LaunchDarkly SDK team will add themselves. The SDK team will acknowledge all pull requests within two business days. +## Release notes and `@since` + +Since this project is meant to be used from multiple LaunchDarkly SDKs and its Javadoc documentation will also appear in the Javadocs for those SDKs, please use the following conventions: + +1. All changes and fixes should be documented in the changelog and release notes for this project as part of the usual release process. +2. They should _also_ be documented in the changelogs and release notes for the next Java/Android SDK releases that incorporate the new `java-sdk-common` release. Users of those should not be expected to monitor this repository; its existence as a separate project is an implementation detail. +3. When adding a new public type or method, include a `@since` tag in its Javadoc comment, in the following format: `@since Java server-side SDK $NEXT_JAVA_VERSION / Android SDK $NEXT_ANDROID_VERSION`, where `$NEXT_JAVA_VERSION` and `$NEXT_ANDROID_VERSION` are the next minor version releases of those SDKs that will incorporate this feature-- even though those have not been released yet. + + ## Build instructions ### Prerequisites diff --git a/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java b/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java index 08d2d6c..0b763c5 100644 --- a/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java @@ -7,8 +7,6 @@ * A builder created by {@link LDValue#buildArray()}. *

* Builder methods are not thread-safe. - * - * @since 4.8.0 */ public final class ArrayBuilder { private List builder = new ArrayList<>(); diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java index aa3dcae..6709750 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java @@ -8,7 +8,6 @@ * 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 { @@ -37,7 +36,6 @@ public EvaluationDetail(EvaluationReason reason, Integer variationIndex, T value * @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); diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java index afc01fd..03823cd 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -13,14 +13,11 @@ * Note that while {@link EvaluationReason} has subclasses as an implementation detail, the subclasses * are not public and may be removed in the future. Always use methods of the base class such as * {@link #getKind()} or {@link #getRuleIndex()} to inspect the reason. - * - * @since 4.3.0 */ @JsonAdapter(EvaluationReasonTypeAdapter.class) public final class EvaluationReason { /** * Enumerated type defining the possible values of {@link EvaluationReason#getKind()}. - * @since 4.3.0 */ public static enum Kind { /** @@ -53,7 +50,7 @@ public static enum Kind { } /** - * Enumerated type defining the possible values of {@link EvaluationReason.Error#getErrorKind()}. + * Enumerated type defining the possible values of {@link #getErrorKind()}. */ public static enum ErrorKind { /** @@ -80,7 +77,7 @@ public static enum ErrorKind { 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()}. + * in this case, and the exception should be available via {@link #getException()}. */ EXCEPTION } @@ -176,9 +173,12 @@ public ErrorKind getErrorKind() { /** * 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}. + * Otherwise {@code null}. + *

+ * Note that the exception will not be included in the JSON serialization of the reason when it + * appears in analytics events; it is only provided informationally for use by application code. * - * @return the exception insta + * @return the exception instance */ public Exception getException() { return exception; @@ -222,7 +222,8 @@ public int hashCode() { } /** - * Returns an instance of {@link Off}. + * Returns an instance whose {@code kind} is {@link Kind#OFF}. + * * @return a reason object */ public static EvaluationReason off() { @@ -230,7 +231,8 @@ public static EvaluationReason off() { } /** - * Returns an instance of {@link Fallthrough}. + * Returns an instance whose {@code kind} is {@link Kind#FALLTHROUGH}. + * * @return a reason object */ public static EvaluationReason fallthrough() { @@ -238,7 +240,8 @@ public static EvaluationReason fallthrough() { } /** - * Returns an instance of {@link TargetMatch}. + * Returns an instance whose {@code kind} is {@link Kind#TARGET_MATCH}. + * * @return a reason object */ public static EvaluationReason targetMatch() { @@ -246,7 +249,8 @@ public static EvaluationReason targetMatch() { } /** - * Returns an instance of {@link RuleMatch}. + * Returns an instance whose {@code kind} is {@link Kind#RULE_MATCH}. + * * @param ruleIndex the rule index * @param ruleId the rule identifier * @return a reason object @@ -256,7 +260,8 @@ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId) { } /** - * Returns an instance of {@link PrerequisiteFailed}. + * Returns an instance whose {@code kind} is {@link Kind#PREREQUISITE_FAILED}. + * * @param prerequisiteKey the flag key of the prerequisite that failed * @return a reason object */ @@ -265,7 +270,8 @@ public static EvaluationReason prerequisiteFailed(String prerequisiteKey) { } /** - * Returns an instance of {@link Error}. + * Returns an instance whose {@code kind} is {@link Kind#ERROR}. + * * @param errorKind describes the type of error * @return a reason object */ @@ -282,7 +288,11 @@ public static EvaluationReason error(ErrorKind errorKind) { } /** - * Returns an instance of {@link Error} with the kind {@link ErrorKind#EXCEPTION} and an exception instance. + * Returns an instance whose {@code kind} is {@link Kind#ERROR}, with an exception instance. + *

+ * Note that the exception will not be included in the JSON serialization of the reason when it + * appears in analytics events; it is only provided informationally for use by application code. + * * @param exception the exception that caused the error * @return a reason object */ diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index 017984b..b9e6938 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -566,7 +566,6 @@ public Builder custom(String k, boolean b) { * @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) { if (k != null) { @@ -648,7 +647,6 @@ public Builder privateCustom(String k, boolean b) { * @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) { if (k != null) { diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index e5e2e11..0f95f88 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -28,8 +28,6 @@ * from the API, so the SDK does not expose this dependency and cannot cause version conflicts in * applications that use Gson themselves. Second, Gson's array and object types are mutable, which can * cause concurrency risks. - * - * @since 4.8.0 */ @JsonAdapter(LDValueTypeAdapter.class) public abstract class LDValue { @@ -426,7 +424,6 @@ public int hashCode() { * complex type. * * @param the type to convert from/to - * @since 4.8.0 */ public static abstract class Converter { /** @@ -527,8 +524,6 @@ public LDValue objectFrom(Map map) { * 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() {} diff --git a/src/main/java/com/launchdarkly/sdk/LDValueType.java b/src/main/java/com/launchdarkly/sdk/LDValueType.java index 11804f6..9780c58 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValueType.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueType.java @@ -2,8 +2,6 @@ /** * Describes the type of an {@link LDValue}. These correspond to the standard types in JSON. - * - * @since 4.8.0 */ public enum LDValueType { /** diff --git a/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java b/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java index cb0157e..48c19fb 100644 --- a/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java @@ -7,8 +7,6 @@ * 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 diff --git a/src/main/java/com/launchdarkly/sdk/UserAttribute.java b/src/main/java/com/launchdarkly/sdk/UserAttribute.java index 0f63e7a..aa61990 100644 --- a/src/main/java/com/launchdarkly/sdk/UserAttribute.java +++ b/src/main/java/com/launchdarkly/sdk/UserAttribute.java @@ -19,8 +19,6 @@ * For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference * guides on Setting user attributes * and Targeting users. - * - * @since 5.0.0 */ @JsonAdapter(UserAttribute.UserAttributeTypeAdapter.class) public final class UserAttribute { diff --git a/src/test/java/com/launchdarkly/sdk/TestHelpers.java b/src/test/java/com/launchdarkly/sdk/TestHelpers.java index 18817b1..cd7bc22 100644 --- a/src/test/java/com/launchdarkly/sdk/TestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/TestHelpers.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Set; +@SuppressWarnings("javadoc") public class TestHelpers { public static List listFromIterable(Iterable it) { List list = new ArrayList<>(); From fe6f0eb43df05c7c682b998bf6bf962fde90d72f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 21 Apr 2020 19:31:43 -0700 Subject: [PATCH 08/91] fix sample code --- src/main/java/com/launchdarkly/sdk/json/GsonTypeAdapters.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/json/GsonTypeAdapters.java b/src/main/java/com/launchdarkly/sdk/json/GsonTypeAdapters.java index 64dfed3..91ffc69 100644 --- a/src/main/java/com/launchdarkly/sdk/json/GsonTypeAdapters.java +++ b/src/main/java/com/launchdarkly/sdk/json/GsonTypeAdapters.java @@ -29,8 +29,10 @@ * An application that wishes to use Gson to serialize or deserialize classes from the SDK should * configure its {@code Gson} instance as follows: *


+ *     import com.launchdarkly.sdk.json.GsonTypeAdapters;
+ *     
  *     Gson gson = new GsonBuilder()
- *         .registerTypeAdapterFactory(new com.launchdarkly.sdk.GsonTypeAdapters())
+ *         .registerTypeAdapterFactory(new GsonTypeAdapters())
  *         // any other GsonBuilder options go here
  *         .create();
  * 
From 97a8fa3a8b091795df504a89f4446ee370cc3606 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Apr 2020 10:08:06 -0700 Subject: [PATCH 09/91] rename Gson adapter, use factory method --- .../launchdarkly/sdk/EvaluationDetail.java | 4 +- .../launchdarkly/sdk/EvaluationReason.java | 4 +- .../java/com/launchdarkly/sdk/LDUser.java | 4 +- .../java/com/launchdarkly/sdk/LDValue.java | 4 +- .../sdk/json/JsonSerializable.java | 2 +- .../sdk/json/JsonSerialization.java | 2 +- .../{GsonTypeAdapters.java => LDGson.java} | 63 +++++++++++++------ .../sdk/json/JsonTestHelpers.java | 14 ++--- 8 files changed, 60 insertions(+), 37 deletions(-) rename src/main/java/com/launchdarkly/sdk/json/{GsonTypeAdapters.java => LDGson.java} (63%) diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java index 61c47d0..b2581ce 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java @@ -1,6 +1,6 @@ package com.launchdarkly.sdk; -import com.launchdarkly.sdk.json.GsonTypeAdapters; +import com.launchdarkly.sdk.json.LDGson; import com.launchdarkly.sdk.json.JsonSerializable; import com.launchdarkly.sdk.json.JsonSerialization; @@ -14,7 +14,7 @@ * {@link EvaluationReason} can be converted to and from JSON in one of two ways: *
    *
  1. With {@link JsonSerialization}. - *
  2. With Gson, if and only if you configure your Gson instance with {@link GsonTypeAdapters}. + *
  3. With Gson, if and only if you configure your Gson instance with {@link LDGson}. *
* * @param the type of the wrapped value diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java index 811ca31..9feab8e 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -1,7 +1,7 @@ package com.launchdarkly.sdk; import com.google.gson.annotations.JsonAdapter; -import com.launchdarkly.sdk.json.GsonTypeAdapters; +import com.launchdarkly.sdk.json.LDGson; import com.launchdarkly.sdk.json.JsonSerializable; import com.launchdarkly.sdk.json.JsonSerialization; @@ -21,7 +21,7 @@ * {@link EvaluationReason} can be converted to and from JSON in one of two ways: *
    *
  1. With {@link JsonSerialization}. - *
  2. With Gson, if and only if you configure your Gson instance with {@link GsonTypeAdapters}. + *
  3. With Gson, if and only if you configure your Gson instance with {@link LDGson}. *
*/ @JsonAdapter(EvaluationReasonTypeAdapter.class) diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index 9461a48..bb920ad 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -1,7 +1,7 @@ package com.launchdarkly.sdk; import com.google.gson.annotations.JsonAdapter; -import com.launchdarkly.sdk.json.GsonTypeAdapters; +import com.launchdarkly.sdk.json.LDGson; import com.launchdarkly.sdk.json.JsonSerializable; import com.launchdarkly.sdk.json.JsonSerialization; @@ -31,7 +31,7 @@ * events. {@link LDUser} can be converted to and from JSON in one of two ways: *
    *
  1. With {@link JsonSerialization}. - *
  2. With Gson, if and only if you configure your Gson instance with {@link GsonTypeAdapters}. + *
  3. With Gson, if and only if you configure your Gson instance with {@link LDGson}. *
*/ @JsonAdapter(LDUserTypeAdapter.class) diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index 6a8017e..e63039a 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -3,7 +3,7 @@ import com.google.gson.Gson; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonWriter; -import com.launchdarkly.sdk.json.GsonTypeAdapters; +import com.launchdarkly.sdk.json.LDGson; import com.launchdarkly.sdk.json.JsonSerializable; import com.launchdarkly.sdk.json.JsonSerialization; import com.launchdarkly.sdk.json.SerializationException; @@ -36,7 +36,7 @@ * {@link LDValue} can be converted to and from JSON in one of three ways: *
    *
  1. With {@link JsonSerialization}. - *
  2. With Gson, if and only if you configure your Gson instance with {@link GsonTypeAdapters}. + *
  3. With Gson, if and only if you configure your Gson instance with {@link LDGson}. *
  4. With the {@link LDValue} methods {@link #toJsonString()} and {@link #parse(String)}. *
*/ diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerializable.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerializable.java index 2290ad7..09ba91c 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerializable.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerializable.java @@ -4,7 +4,7 @@ * Marker interface for SDK classes that have a custom JSON serialization. * * @see JsonSerialization - * @see GsonTypeAdapters + * @see LDGson */ public interface JsonSerializable { } diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index 8c10854..d046cec 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -16,7 +16,7 @@ * the following: *
    *
  1. The {@link JsonSerialization} methods. - *
  2. A Gson instance that has been configured with {@link GsonTypeAdapters}. + *
  3. A Gson instance that has been configured with {@link LDGson}. *
  4. For {@link LDValue}, you may also use the convenience methods {@link LDValue#toJsonString()} and * {@link LDValue#parse(String)}. *
diff --git a/src/main/java/com/launchdarkly/sdk/json/GsonTypeAdapters.java b/src/main/java/com/launchdarkly/sdk/json/LDGson.java similarity index 63% rename from src/main/java/com/launchdarkly/sdk/json/GsonTypeAdapters.java rename to src/main/java/com/launchdarkly/sdk/json/LDGson.java index 91ffc69..ecfda0f 100644 --- a/src/main/java/com/launchdarkly/sdk/json/GsonTypeAdapters.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDGson.java @@ -29,12 +29,12 @@ * An application that wishes to use Gson to serialize or deserialize classes from the SDK should * configure its {@code Gson} instance as follows: *

- *     import com.launchdarkly.sdk.json.GsonTypeAdapters;
+ *     import com.launchdarkly.sdk.json.LDGson;
  *     
  *     Gson gson = new GsonBuilder()
- *         .registerTypeAdapterFactory(new GsonTypeAdapters())
- *         // any other GsonBuilder options go here
- *         .create();
+ *       .registerTypeAdapterFactory(LDGson.typeAdapters())
+ *       // any other GsonBuilder options go here
+ *       .create();
  * 
*

* This causes Gson to use the correct JSON representation logic (the same that would be used by @@ -45,34 +45,57 @@ *

* Note that some of the LaunchDarkly SDK distributions deliberately do not expose Gson as a * dependency, so if you are using Gson in your application you will need to make sure you have - * defined your own dependency on it. Referencing {@link GsonTypeAdapters} will cause a runtime + * defined your own dependency on it. Referencing {@link LDGson} will cause a runtime * exception if Gson is not in the caller's classpath. */ -public class GsonTypeAdapters implements TypeAdapterFactory { +public abstract class LDGson { + // Implementation note: - // The main reason this class exists is the Java server-side SDK's issue with Gson interoperability - // due to the use of shading in the default jar artifact. If the Gson type references in this class + // The reason this class exists is the Java server-side SDK's issue with Gson interoperability due + // to the use of shading in the default jar artifact. If the Gson type references in this class // were also shaded in the SDK jar, then this class would not work with an unshaded Gson instance, // which would defeat the whole purpose. Therefore, the Java SDK build will need to have special- - // case handling for this class when it builds the jar, and embed the original class file instead - // of one that has had shading applied. By design, none of the other Gson-related classes in this - // project would need such special handling; in the Java server-side SDK jar, they would be meant - // to use the shaded copy of Gson. + // case handling for this class (and its inner classes) when it builds the jar, and embed the + // original class files instead of the ones that have had shading applied. By design, none of the + // other Gson-related classes in this project would need such special handling; in the Java + // server-side SDK jar, they would be meant to use the shaded copy of Gson. - @Override - public TypeAdapter create(Gson gson, TypeToken type) { - if (JsonSerializable.class.isAssignableFrom(type.getRawType())) { - return new GsonTypeAdapter<>(gson, type); - } - return null; + /** + * Returns a Gson {@code TypeAdapterFactory} that defines the correct serialization and + * deserialization behavior for all LaunchDarkly SDK objects that implement {@link JsonSerializable}. + *


+   *     import com.launchdarkly.sdk.json.LDGson;
+   *     
+   *     Gson gson = new GsonBuilder()
+   *       .registerTypeAdapterFactory(LDGson.typeAdapters())
+   *       // any other GsonBuilder options go here
+   *       .create();
+   * 
+ * @return a {@code TypeAdapterFactory} + */ + public static TypeAdapterFactory typeAdapters() { + return LDTypeAdapterFactory.INSTANCE; } - private static class GsonTypeAdapter extends TypeAdapter { + private static class LDTypeAdapterFactory implements TypeAdapterFactory { + // Note that this static initializer will only run if application code actually references LDGson. + private static LDTypeAdapterFactory INSTANCE = new LDTypeAdapterFactory(); + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + if (JsonSerializable.class.isAssignableFrom(type.getRawType())) { + return new LDTypeAdapter(gson, type); + } + return null; + } + } + + private static class LDTypeAdapter extends TypeAdapter { private final Gson gson; private final Class objectClass; @SuppressWarnings("unchecked") - GsonTypeAdapter(Gson gson, TypeToken type) { + LDTypeAdapter(Gson gson, TypeToken type) { this.gson = gson; this.objectClass = (Class)type.getRawType(); } diff --git a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java index 867f269..25b7a85 100644 --- a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java @@ -8,11 +8,11 @@ @SuppressWarnings("javadoc") public abstract class JsonTestHelpers { - // Note that when we verify the behavior of Gson with GsonTypeAdapters in this project's unit tests, - // that is not an adequate test for whether the adapters will work in the Java SDK where there is - // the additional issue of Gson types being shaded. The Java SDK project must do its own basic tests - // of Gson interoperability using the shaded SDK jar. But the tests in this project still prove that - // the adapters work correctly if Gson actually uses them. + // Note that when we verify the behavior of Gson with LDGson in this project's unit tests, that + // is not an adequate test for whether the adapters will work in the Java SDK where there is the + // additional issue of Gson types being shaded. The Java SDK project must do its own basic tests + // of Gson interoperability using the shaded SDK jar. But the tests in this project still prove + // that the adapters work correctly if Gson actually uses them. public static void verifySerializeAndDeserialize(T instance, String expectedJsonString) throws Exception { verifySerialize(instance, expectedJsonString); @@ -24,7 +24,7 @@ public static void verifySerialize(T instance, Stri // but since some of our tests are testing LDValue itself, we can't assume that its behavior is correct. assertEquals(parseElement(expectedJsonString), parseElement(JsonSerialization.serialize(instance))); - Gson gson = new GsonBuilder().registerTypeAdapterFactory(new GsonTypeAdapters()).create(); + Gson gson = new GsonBuilder().registerTypeAdapterFactory(LDGson.typeAdapters()).create(); assertEquals(parseElement(expectedJsonString), parseElement(gson.toJson(instance))); } @@ -33,7 +33,7 @@ public static void verifyDeserialize(T instance, St T instance1 = JsonSerialization.deserialize(expectedJsonString, (Class)instance.getClass()); assertEquals(instance, instance1); - Gson gson = new GsonBuilder().registerTypeAdapterFactory(new GsonTypeAdapters()).create(); + Gson gson = new GsonBuilder().registerTypeAdapterFactory(LDGson.typeAdapters()).create(); T instance2 = gson.fromJson(expectedJsonString, (Class)instance.getClass()); assertEquals(instance, instance2); } From 511c9e22f156bf49a428ebb0630304348ef20a74 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 23 Apr 2020 20:34:28 -0700 Subject: [PATCH 10/91] improve and rigorously test equals() for all immutable types --- .../launchdarkly/sdk/EvaluationDetail.java | 10 +- .../launchdarkly/sdk/EvaluationReason.java | 3 + .../java/com/launchdarkly/sdk/Helpers.java | 16 --- .../java/com/launchdarkly/sdk/LDUser.java | 41 +++---- .../java/com/launchdarkly/sdk/LDValue.java | 3 + .../com/launchdarkly/sdk/UserAttribute.java | 2 +- .../sdk/EvaluationDetailTest.java | 48 ++++++++ .../sdk/EvaluationReasonTest.java | 21 ++++ .../java/com/launchdarkly/sdk/LDUserTest.java | 51 +++++++++ .../com/launchdarkly/sdk/LDValueTest.java | 104 ++++++------------ .../com/launchdarkly/sdk/TestHelpers.java | 23 ++++ .../launchdarkly/sdk/UserAttributeTest.java | 15 +++ 12 files changed, 227 insertions(+), 110 deletions(-) create mode 100644 src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java index 6709750..e915910 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk; -import static com.launchdarkly.sdk.Helpers.hashFrom; -import static com.launchdarkly.sdk.Helpers.objectsEqual; +import java.util.Objects; /** * An object returned by the SDK's "variation detail" methods such as {@code boolVariationDetail}, @@ -89,17 +88,20 @@ public boolean isDefaultValue() { @Override public boolean equals(Object other) { + if (other == this) { + return true; + } if (other instanceof EvaluationDetail) { @SuppressWarnings("unchecked") EvaluationDetail o = (EvaluationDetail)other; - return objectsEqual(reason, o.reason) && objectsEqual(variationIndex, o.variationIndex) && objectsEqual(value, o.value); + return Objects.equals(reason, o.reason) && Objects.equals(variationIndex, o.variationIndex) && Objects.equals(value, o.value); } return false; } @Override public int hashCode() { - return hashFrom(reason, variationIndex, value); + return Objects.hash(reason, variationIndex, value); } @Override diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java index 03823cd..3439d98 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -207,6 +207,9 @@ public String toString() { @Override public boolean equals(Object other) { + if (other == this) { + return true; + } if (other instanceof EvaluationReason) { EvaluationReason o = (EvaluationReason)other; return kind == o.kind && ruleIndex == o.ruleIndex && Objects.equals(ruleId, o.ruleId)&& diff --git a/src/main/java/com/launchdarkly/sdk/Helpers.java b/src/main/java/com/launchdarkly/sdk/Helpers.java index 20a292c..6a42c32 100644 --- a/src/main/java/com/launchdarkly/sdk/Helpers.java +++ b/src/main/java/com/launchdarkly/sdk/Helpers.java @@ -7,22 +7,6 @@ * library because the Android SDK does not have it. */ abstract class Helpers { - static boolean objectsEqual(Object a, Object b) { - if (a == null) { - return b == null; - } else { - return b != null && a.equals(b); - } - } - - static int hashFrom(Object... values) { - int result = 0; - for (Object value: values) { - result = result * 31 + (value == null ? 0 : value.hashCode()); - } - return result; - } - // This implementation is much simpler than Guava's Iterables.transform() because it does not attempt // to support remove(). static Iterable transform(final Iterable source, final Function fn) { diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index b9e6938..2aaab16 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -6,10 +6,9 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Map; +import java.util.Objects; import java.util.Set; -import static com.launchdarkly.sdk.Helpers.hashFrom; -import static com.launchdarkly.sdk.Helpers.objectsEqual; import static java.util.Collections.unmodifiableMap; import static java.util.Collections.unmodifiableSet; @@ -231,28 +230,30 @@ public String toJsonString() { @Override public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - LDUser ldUser = (LDUser) o; - - return objectsEqual(key, ldUser.key) && - objectsEqual(secondary, ldUser.secondary) && - objectsEqual(ip, ldUser.ip) && - objectsEqual(email, ldUser.email) && - objectsEqual(name, ldUser.name) && - objectsEqual(avatar, ldUser.avatar) && - objectsEqual(firstName, ldUser.firstName) && - objectsEqual(lastName, ldUser.lastName) && - objectsEqual(anonymous, ldUser.anonymous) && - objectsEqual(country, ldUser.country) && - objectsEqual(custom, ldUser.custom) && - objectsEqual(privateAttributeNames, ldUser.privateAttributeNames); + if (this == o) { + return true; + } + if (o instanceof LDUser) { + 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); + } + return false; } @Override public int hashCode() { - return hashFrom(key, secondary, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); + return Objects.hash(key, secondary, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); } /** diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index 0f95f88..52c133d 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -360,6 +360,9 @@ public String toString() { @Override public boolean equals(Object o) { if (o instanceof LDValue) { + if (o == this) { + return true; + } LDValue other = (LDValue)o; if (getType() == other.getType()) { switch (getType()) { diff --git a/src/main/java/com/launchdarkly/sdk/UserAttribute.java b/src/main/java/com/launchdarkly/sdk/UserAttribute.java index aa61990..47d403e 100644 --- a/src/main/java/com/launchdarkly/sdk/UserAttribute.java +++ b/src/main/java/com/launchdarkly/sdk/UserAttribute.java @@ -113,7 +113,7 @@ public LDValue apply(LDUser u) { }); - private static final Map BUILTINS; + static final Map BUILTINS; static { BUILTINS = new HashMap<>(); for (UserAttribute a: new UserAttribute[] { KEY, SECONDARY_KEY, IP, EMAIL, NAME, AVATAR, FIRST_NAME, LAST_NAME, COUNTRY, ANONYMOUS }) { diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java new file mode 100644 index 0000000..8a7eee1 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java @@ -0,0 +1,48 @@ +package com.launchdarkly.sdk; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +@SuppressWarnings("javadoc") +public class EvaluationDetailTest { + @Test + public void equalInstancesAreEqual() { + List>> testValues = Arrays.asList( + Arrays.asList( + new EvaluationDetail<>(EvaluationReason.off(), 0, "a"), + new EvaluationDetail<>(EvaluationReason.off(), 0, "a") + ), + Arrays.asList( + new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, "a"), + new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, "a") + ), + Arrays.asList( + new EvaluationDetail<>(EvaluationReason.off(), 1, "a"), + new EvaluationDetail<>(EvaluationReason.off(), 1, "a") + ), + Arrays.asList( + new EvaluationDetail<>(EvaluationReason.fallthrough(), 1, "a"), + new EvaluationDetail<>(EvaluationReason.fallthrough(), 1, "a") + ), + Arrays.asList( + new EvaluationDetail<>(EvaluationReason.off(), 0, "b"), + new EvaluationDetail<>(EvaluationReason.off(), 0, "b") + ), + Arrays.asList( + new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, "b"), + new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, "b") + ), + Arrays.asList( + new EvaluationDetail<>(EvaluationReason.off(), 1, "b"), + new EvaluationDetail<>(EvaluationReason.off(), 1, "b") + ), + Arrays.asList( + new EvaluationDetail<>(EvaluationReason.fallthrough(), 1, "b"), + new EvaluationDetail<>(EvaluationReason.fallthrough(), 1, "b") + ) + ); + TestHelpers.doEqualityTests(testValues); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java index f2608d5..ee24cbd 100644 --- a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java @@ -4,6 +4,11 @@ import org.junit.Test; +import java.util.Arrays; +import java.util.List; + +import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.CLIENT_NOT_READY; +import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.WRONG_TYPE; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; @@ -79,6 +84,22 @@ public void errorInstancesAreReused() { } } + @Test + public void equalInstancesAreEqual() { + List> testValues = Arrays.asList( + Arrays.asList(EvaluationReason.off(), EvaluationReason.off()), + Arrays.asList(EvaluationReason.fallthrough(), EvaluationReason.fallthrough()), + Arrays.asList(EvaluationReason.targetMatch(), EvaluationReason.targetMatch()), + Arrays.asList(EvaluationReason.ruleMatch(1, "id1"), EvaluationReason.ruleMatch(1, "id1")), + Arrays.asList(EvaluationReason.ruleMatch(1, "id2"), EvaluationReason.ruleMatch(1, "id2")), + Arrays.asList(EvaluationReason.ruleMatch(2, "id1"), EvaluationReason.ruleMatch(2, "id1")), + Arrays.asList(EvaluationReason.prerequisiteFailed("a"), EvaluationReason.prerequisiteFailed("a")), + Arrays.asList(EvaluationReason.error(CLIENT_NOT_READY), EvaluationReason.error(CLIENT_NOT_READY)), + Arrays.asList(EvaluationReason.error(WRONG_TYPE), EvaluationReason.error(WRONG_TYPE)) + ); + TestHelpers.doEqualityTests(testValues); + } + private void assertJsonEqual(String expectedString, String actualString) { LDValue expected = LDValue.parse(expectedString); LDValue actual = LDValue.parse(actualString); diff --git a/src/test/java/com/launchdarkly/sdk/LDUserTest.java b/src/test/java/com/launchdarkly/sdk/LDUserTest.java index ab51fee..9ba950c 100644 --- a/src/test/java/com/launchdarkly/sdk/LDUserTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDUserTest.java @@ -2,11 +2,13 @@ import org.junit.Test; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import static com.launchdarkly.sdk.Helpers.transform; import static com.launchdarkly.sdk.TestHelpers.setFromIterable; +import static java.util.Arrays.asList; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.emptyIterable; @@ -261,6 +263,55 @@ public void getAttributeGetsBuiltInAttributeEvenIfCustomAttrHasSameName() { assertEquals(LDValue.of("Jane"), user.getAttribute(UserAttribute.forName("name"))); } + @Test + public void equalValuesAreEqual() { + String key = "key"; + List> testValues = new ArrayList<>(); + testValues.add(asList(new LDUser(key), new LDUser(key))); + testValues.add(asList(new LDUser("key2"), new LDUser("key2"))); + for (OptionalStringAttributes a: OptionalStringAttributes.values()) { + List equalValues = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + LDUser.Builder builder = new LDUser.Builder(key); + a.setter.apply(builder, "x"); + equalValues.add(builder.build()); + } + testValues.add(equalValues); + List equalValuesPrivate = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + LDUser.Builder builder = new LDUser.Builder(key); + a.privateSetter.apply(builder, "x"); + equalValuesPrivate.add(builder.build()); + } + testValues.add(equalValuesPrivate); + } + for (boolean anonValue: new boolean[] { true, false }) { + List equalValues = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + equalValues.add(new LDUser.Builder(key).anonymous(anonValue).build()); + } + testValues.add(equalValues); + } + for (String attrName: new String[] { "custom1", "custom2" }) { + LDValue[] values = new LDValue[] { LDValue.of(true), LDValue.of(false) }; + for (LDValue attrValue: values) { + List equalValues = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + LDUser.Builder builder = new LDUser.Builder(key).custom(attrName, attrValue); + equalValues.add(builder.build()); + } + testValues.add(equalValues); + } + List equalValues = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + LDUser.Builder builder = new LDUser.Builder(key).privateCustom(attrName, values[0]); + equalValues.add(builder.build()); + } + testValues.add(equalValues); + } + TestHelpers.doEqualityTests(testValues); + } + @Test public void testMinimalJsonEncoding() { LDUser user = new LDUser("userkey"); diff --git a/src/test/java/com/launchdarkly/sdk/LDValueTest.java b/src/test/java/com/launchdarkly/sdk/LDValueTest.java index a569d69..9b4eff8 100644 --- a/src/test/java/com/launchdarkly/sdk/LDValueTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueTest.java @@ -10,6 +10,7 @@ import java.util.Map; import static com.launchdarkly.sdk.TestHelpers.listFromIterable; +import static java.util.Arrays.asList; import static java.util.Collections.addAll; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -287,77 +288,42 @@ public void nonObjectValuesBehaveLikeEmptyObject() { } @Test - public void testEqualsAndHashCodeForPrimitives() + public void equalValuesAreEqual() { - 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)); + List> testValues = asList( + asList(LDValue.ofNull(), LDValue.ofNull()), + asList(LDValue.of(true), LDValue.of(true)), + asList(LDValue.of(false), LDValue.of(false)), + asList(LDValue.of(1), LDValue.of(1)), + asList(LDValue.of(2), LDValue.of(2)), + asList(LDValue.of(3), LDValue.of(3.0f)), + asList(LDValue.of("a"), LDValue.of("a")), + asList(LDValue.of("b"), LDValue.of("b")), + + // arrays use deep equality + asList(LDValue.buildArray().build(), LDValue.buildArray().build()), + asList(LDValue.buildArray().add("a").build(), LDValue.buildArray().add("a").build()), + asList(LDValue.buildArray().add("a").add("b").build(), + LDValue.buildArray().add("a").add("b").build()), + asList(LDValue.buildArray().add("a").add("c").build(), + LDValue.buildArray().add("a").add("c").build()), + asList(LDValue.buildArray().add("a").add(LDValue.buildArray().add("b").add("c").build()).build(), + LDValue.buildArray().add("a").add(LDValue.buildArray().add("b").add("c").build()).build()), + asList(LDValue.buildArray().add("a").add(LDValue.buildArray().add("b").add("d").build()).build(), + LDValue.buildArray().add("a").add(LDValue.buildArray().add("b").add("d").build()).build()), + + // objects use deep equality + asList(LDValue.buildObject().build(), LDValue.buildObject().build()), + asList(LDValue.buildObject().put("a", LDValue.of(1)).build(), + LDValue.buildObject().put("a", LDValue.of(1)).build()), + asList(LDValue.buildObject().put("a", LDValue.of(2)).build(), + LDValue.buildObject().put("a", LDValue.of(2)).build()), + asList(LDValue.buildObject().put("a", LDValue.of(1)).put("b", LDValue.of(2)).build(), + LDValue.buildObject().put("b", LDValue.of(2)).put("a", LDValue.of(1)).build()) + ); + TestHelpers.doEqualityTests(testValues); } - - 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 equalsUsesDeepEqualityForArrays() - { - LDValue a1 = LDValue.buildArray().add("a") - .add(LDValue.buildArray().add("b").add("c").build()) - .build(); - - LDValue a2 = LDValue.buildArray().add("a").build(); - assertValueAndHashNotEqual(a1, a2); - - LDValue a3 = LDValue.buildArray().add("a").add("b").add("c").build(); - assertValueAndHashNotEqual(a1, a3); - - LDValue a4 = LDValue.buildArray().add("a") - .add(LDValue.buildArray().add("b").add("x").build()) - .build(); - assertValueAndHashNotEqual(a1, a4); - } - - @Test - public void equalsUsesDeepEqualityForObjects() - { - LDValue o1 = LDValue.buildObject() - .put("a", "b") - .put("c", LDValue.buildObject().put("d", "e").build()) - .build(); - - LDValue o2 = LDValue.buildObject() - .put("a", "b") - .build(); - assertValueAndHashNotEqual(o1, o2); - - LDValue o3 = LDValue.buildObject() - .put("a", "b") - .put("c", LDValue.buildObject().put("d", "e").build()) - .put("f", "g") - .build(); - assertValueAndHashNotEqual(o1, o3); - - LDValue o4 = LDValue.buildObject() - .put("a", "b") - .put("c", LDValue.buildObject().put("d", "f").build()) - .build(); - assertValueAndHashNotEqual(o1, o4); - } - + @Test public void canUseLongTypeForNumberGreaterThanMaxInt() { long n = (long)Integer.MAX_VALUE + 1; diff --git a/src/test/java/com/launchdarkly/sdk/TestHelpers.java b/src/test/java/com/launchdarkly/sdk/TestHelpers.java index cd7bc22..e061219 100644 --- a/src/test/java/com/launchdarkly/sdk/TestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/TestHelpers.java @@ -5,6 +5,9 @@ import java.util.List; import java.util.Set; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + @SuppressWarnings("javadoc") public class TestHelpers { public static List listFromIterable(Iterable it) { @@ -22,4 +25,24 @@ public static Set setFromIterable(Iterable it) { } return set; } + + public static void doEqualityTests(List> testValues) { + // Each element of testValues should be a list of two values that should be equal to each other, + // but not equal to any of the other values in testValues. It would have been nicer to use a + // single function that *creates* a value and call it twice, but since we can't use lambdas in + // Java 7 that would be very verbose. + for (int i = 0; i < testValues.size(); i++) { + List equalValues = testValues.get(i); + assertEquals(equalValues.get(0), equalValues.get(0)); + assertEquals(equalValues.get(0), equalValues.get(1)); + assertEquals(equalValues.get(1), equalValues.get(0)); + assertEquals(equalValues.get(0).hashCode(), equalValues.get(1).hashCode()); + for (int j = 0; j < testValues.size(); j++) { + if (j != i) { + assertNotEquals(testValues.get(j).get(0), equalValues.get(0)); + assertNotEquals(equalValues.get(0), testValues.get(j).get(0)); + } + } + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java index 04a2d5f..e3a6055 100644 --- a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java +++ b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java @@ -2,6 +2,10 @@ import org.junit.Test; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -67,4 +71,15 @@ public void customAttribute() { assertEquals("things", UserAttribute.forName("things").getName()); assertFalse(UserAttribute.forName("things").isBuiltIn()); } + + @Test + public void equalInstancesAreEqual() { + List> testValues = new ArrayList<>(); + for (UserAttribute attr: UserAttribute.BUILTINS.values()) { + testValues.add(Arrays.asList(attr, UserAttribute.forName(attr.getName()))); + } + testValues.add(Arrays.asList(UserAttribute.forName("a"), UserAttribute.forName("a"))); + testValues.add(Arrays.asList(UserAttribute.forName("b"), UserAttribute.forName("b"))); + TestHelpers.doEqualityTests(testValues); + } } From 525daa6932b61cfd304c6b07dfbe698289eb162a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 23 Apr 2020 20:41:03 -0700 Subject: [PATCH 11/91] simplifying --- .../sdk/EvaluationDetailTest.java | 48 +++++-------------- .../sdk/EvaluationReasonTest.java | 22 ++++----- 2 files changed, 24 insertions(+), 46 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java index 8a7eee1..91046c5 100644 --- a/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java +++ b/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java @@ -2,47 +2,25 @@ import org.junit.Test; -import java.util.Arrays; +import java.util.ArrayList; import java.util.List; @SuppressWarnings("javadoc") public class EvaluationDetailTest { @Test public void equalInstancesAreEqual() { - List>> testValues = Arrays.asList( - Arrays.asList( - new EvaluationDetail<>(EvaluationReason.off(), 0, "a"), - new EvaluationDetail<>(EvaluationReason.off(), 0, "a") - ), - Arrays.asList( - new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, "a"), - new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, "a") - ), - Arrays.asList( - new EvaluationDetail<>(EvaluationReason.off(), 1, "a"), - new EvaluationDetail<>(EvaluationReason.off(), 1, "a") - ), - Arrays.asList( - new EvaluationDetail<>(EvaluationReason.fallthrough(), 1, "a"), - new EvaluationDetail<>(EvaluationReason.fallthrough(), 1, "a") - ), - Arrays.asList( - new EvaluationDetail<>(EvaluationReason.off(), 0, "b"), - new EvaluationDetail<>(EvaluationReason.off(), 0, "b") - ), - Arrays.asList( - new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, "b"), - new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, "b") - ), - Arrays.asList( - new EvaluationDetail<>(EvaluationReason.off(), 1, "b"), - new EvaluationDetail<>(EvaluationReason.off(), 1, "b") - ), - Arrays.asList( - new EvaluationDetail<>(EvaluationReason.fallthrough(), 1, "b"), - new EvaluationDetail<>(EvaluationReason.fallthrough(), 1, "b") - ) - ); + List>> testValues = new ArrayList<>(); + for (EvaluationReason reason: new EvaluationReason[] { EvaluationReason.off(), EvaluationReason.fallthrough() }) { + for (int variation = 0; variation < 2; variation++) { + for (String value: new String[] { "a", "b" }) { + List> equalValues = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + equalValues.add(new EvaluationDetail<>(reason, variation, value)); + } + testValues.add(equalValues); + } + } + } TestHelpers.doEqualityTests(testValues); } } diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java index ee24cbd..6d67194 100644 --- a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java @@ -4,11 +4,11 @@ import org.junit.Test; -import java.util.Arrays; import java.util.List; import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.CLIENT_NOT_READY; import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.WRONG_TYPE; +import static java.util.Arrays.asList; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; @@ -86,16 +86,16 @@ public void errorInstancesAreReused() { @Test public void equalInstancesAreEqual() { - List> testValues = Arrays.asList( - Arrays.asList(EvaluationReason.off(), EvaluationReason.off()), - Arrays.asList(EvaluationReason.fallthrough(), EvaluationReason.fallthrough()), - Arrays.asList(EvaluationReason.targetMatch(), EvaluationReason.targetMatch()), - Arrays.asList(EvaluationReason.ruleMatch(1, "id1"), EvaluationReason.ruleMatch(1, "id1")), - Arrays.asList(EvaluationReason.ruleMatch(1, "id2"), EvaluationReason.ruleMatch(1, "id2")), - Arrays.asList(EvaluationReason.ruleMatch(2, "id1"), EvaluationReason.ruleMatch(2, "id1")), - Arrays.asList(EvaluationReason.prerequisiteFailed("a"), EvaluationReason.prerequisiteFailed("a")), - Arrays.asList(EvaluationReason.error(CLIENT_NOT_READY), EvaluationReason.error(CLIENT_NOT_READY)), - Arrays.asList(EvaluationReason.error(WRONG_TYPE), EvaluationReason.error(WRONG_TYPE)) + List> testValues = asList( + asList(EvaluationReason.off(), EvaluationReason.off()), + asList(EvaluationReason.fallthrough(), EvaluationReason.fallthrough()), + asList(EvaluationReason.targetMatch(), EvaluationReason.targetMatch()), + asList(EvaluationReason.ruleMatch(1, "id1"), EvaluationReason.ruleMatch(1, "id1")), + asList(EvaluationReason.ruleMatch(1, "id2"), EvaluationReason.ruleMatch(1, "id2")), + asList(EvaluationReason.ruleMatch(2, "id1"), EvaluationReason.ruleMatch(2, "id1")), + asList(EvaluationReason.prerequisiteFailed("a"), EvaluationReason.prerequisiteFailed("a")), + asList(EvaluationReason.error(CLIENT_NOT_READY), EvaluationReason.error(CLIENT_NOT_READY)), + asList(EvaluationReason.error(WRONG_TYPE), EvaluationReason.error(WRONG_TYPE)) ); TestHelpers.doEqualityTests(testValues); } From 3e222c377e7f428be7659a2b613b4f8fa028448f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 23 Apr 2020 20:42:17 -0700 Subject: [PATCH 12/91] simplifying --- src/test/java/com/launchdarkly/sdk/UserAttributeTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java index e3a6055..c90e19b 100644 --- a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java +++ b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java @@ -3,9 +3,9 @@ import org.junit.Test; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; +import static java.util.Arrays.asList; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -76,10 +76,10 @@ public void customAttribute() { public void equalInstancesAreEqual() { List> testValues = new ArrayList<>(); for (UserAttribute attr: UserAttribute.BUILTINS.values()) { - testValues.add(Arrays.asList(attr, UserAttribute.forName(attr.getName()))); + testValues.add(asList(attr, UserAttribute.forName(attr.getName()))); } - testValues.add(Arrays.asList(UserAttribute.forName("a"), UserAttribute.forName("a"))); - testValues.add(Arrays.asList(UserAttribute.forName("b"), UserAttribute.forName("b"))); + testValues.add(asList(UserAttribute.forName("custom1"), UserAttribute.forName("custom1"))); + testValues.add(asList(UserAttribute.forName("custom2"), UserAttribute.forName("custom2"))); TestHelpers.doEqualityTests(testValues); } } From d0031797be81ee0136ad896e9968728a34ce9908 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 24 Apr 2020 11:19:05 -0700 Subject: [PATCH 13/91] make EvaluationDetail non-nullable + use boolean singletons --- .../launchdarkly/sdk/EvaluationDetail.java | 98 ++++++++++++++----- .../com/launchdarkly/sdk/LDValueBool.java | 4 +- .../sdk/EvaluationDetailTest.java | 60 +++++++++++- 3 files changed, 135 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java index e915910..3a7753d 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; /** @@ -9,21 +11,23 @@ * @param the type of the wrapped value */ 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 + * If {@link #getVariationIndex()} is equal to this constant, it means no variation was chosen + * (evaluation failed and returned a default value). */ - public EvaluationDetail(EvaluationReason reason, Integer variationIndex, T value) { + public static final int NO_VARIATION = -1; + + private static final Iterable> BOOLEAN_SINGLETONS = createBooleanSingletons(); + + private final T value; + private final int variationIndex; + private final EvaluationReason reason; + + // Constructor is private to allow us to use different creation strategies in the factory method + // (such as interning some commonly used instances). + private EvaluationDetail(T value, int variationIndex, EvaluationReason reason) { this.value = value; - this.variationIndex = variationIndex; + this.variationIndex = variationIndex >= 0 ? variationIndex : NO_VARIATION; this.reason = reason; } @@ -32,12 +36,23 @@ public EvaluationDetail(EvaluationReason reason, Integer variationIndex, T value * * @param the type of the value * @param value a value of the desired type - * @param variationIndex an optional variation index + * @param variationIndex a variation index, or {@link #NO_VARIATION} (any negative number will be + * changed to {@link #NO_VARIATION}) * @param reason an {@link EvaluationReason} (should not be null) * @return an {@link EvaluationDetail} */ - public static EvaluationDetail fromValue(T value, Integer variationIndex, EvaluationReason reason) { - return new EvaluationDetail(reason, variationIndex, value); + @SuppressWarnings("unchecked") + public static EvaluationDetail fromValue(T value, int variationIndex, EvaluationReason reason) { + // Return an existing singleton if possible to avoid creating a lot of ephemeral objects for the + // typical boolean flag cases. + if (value != null && (value.getClass() == Boolean.class || value.getClass() == LDValueBool.class)) { + for (EvaluationDetail d: BOOLEAN_SINGLETONS) { + if (d.value == value && d.variationIndex == variationIndex && d.reason == reason) { + return (EvaluationDetail)d; + } + } + } + return new EvaluationDetail(value, variationIndex, reason); } /** @@ -48,11 +63,12 @@ public static EvaluationDetail fromValue(T value, Integer variationIndex, * @return an {@link EvaluationDetail} */ public static EvaluationDetail error(EvaluationReason.ErrorKind errorKind, LDValue defaultValue) { - return new EvaluationDetail(EvaluationReason.error(errorKind), null, LDValue.normalize(defaultValue)); + return new EvaluationDetail(LDValue.normalize(defaultValue), NO_VARIATION, EvaluationReason.error(errorKind)); } /** * An object describing the main factor that influenced the flag evaluation value. + * * @return an {@link EvaluationReason} */ public EvaluationReason getReason() { @@ -60,17 +76,19 @@ public EvaluationReason getReason() { } /** - * 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 + * The index of the returned value within the flag's list of variations, e.g. 0 for the first variation, + * or {@link #NO_VARIATION}. + * + * @return the variation index if applicable */ - public Integer getVariationIndex() { + public int 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() { @@ -79,11 +97,12 @@ public T getValue() { /** * Returns true if the flag evaluation returned the default value, rather than one of the flag's - * variations. + * variations. If so, {@link #getVariationIndex()} will be {@link #NO_VARIATION}. + * * @return true if this is the default value */ public boolean isDefaultValue() { - return variationIndex == null; + return variationIndex < 0; } @Override @@ -94,7 +113,7 @@ public boolean equals(Object other) { if (other instanceof EvaluationDetail) { @SuppressWarnings("unchecked") EvaluationDetail o = (EvaluationDetail)other; - return Objects.equals(reason, o.reason) && Objects.equals(variationIndex, o.variationIndex) && Objects.equals(value, o.value); + return Objects.equals(reason, o.reason) && variationIndex == o.variationIndex && Objects.equals(value, o.value); } return false; } @@ -104,8 +123,39 @@ public int hashCode() { return Objects.hash(reason, variationIndex, value); } + /** + * Returns a simple string representation of this instance. + *

+ * This is a convenience method for debugging and any other use cases where a human-readable string is + * helpful. The exact format of the string is subject to change; if you need to make programmatic + * decisions based on the object's properties, use other methods like {@link #getValue()}. + */ @Override public String toString() { - return "{" + reason + "," + variationIndex + "," + value + "}"; + return "{" + value + "," + variationIndex + "," + reason + "}"; + } + + private static Iterable> createBooleanSingletons() { + // Boolean flags are very commonly used, so we'll generate likely combinations here because it's + // better to iterate through a few more array elements than to create an instance. Note that the + // internal evaluation logic will use LDValue whereas boolVariation() uses Boolean. + List> ret = new ArrayList<>(); + + // It's more common for false to be variation 0, so put that first + for (int iFalseVariation = 0; iFalseVariation < 2; iFalseVariation++) { + // It's more common for the off variation to be variation 0, so put that first + for (int iOffVariation = 0; iOffVariation < 2; iOffVariation++) { + for (int iTruth = 0; iTruth < 2; iTruth++) { + for (int iType = 0; iType < 2; iType++) { + Object value = iType == 0 ? LDValue.of(iTruth == 1) : Boolean.valueOf(iTruth == 1); + int variationIndex = iTruth == 0 ? iFalseVariation : (1 - iFalseVariation); + EvaluationReason reason = variationIndex == iOffVariation ? EvaluationReason.off() : EvaluationReason.fallthrough(); + ret.add(new EvaluationDetail(value, variationIndex, reason)); + } + } + } + } + + return ret; } } diff --git a/src/main/java/com/launchdarkly/sdk/LDValueBool.java b/src/main/java/com/launchdarkly/sdk/LDValueBool.java index f68789e..8f72c84 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValueBool.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueBool.java @@ -7,8 +7,8 @@ @JsonAdapter(LDValueTypeAdapter.class) final class LDValueBool extends LDValue { - private static final LDValueBool TRUE = new LDValueBool(true); - private static final LDValueBool FALSE = new LDValueBool(false); + static final LDValueBool TRUE = new LDValueBool(true); + static final LDValueBool FALSE = new LDValueBool(false); private final boolean value; diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java index 91046c5..2c96daa 100644 --- a/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java +++ b/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java @@ -5,8 +5,44 @@ import java.util.ArrayList; import java.util.List; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.CLIENT_NOT_READY; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + @SuppressWarnings("javadoc") public class EvaluationDetailTest { + @Test + public void getValue() { + assertEquals("x", EvaluationDetail.fromValue("x", 0, EvaluationReason.off()).getValue()); + assertEquals(LDValue.of("x"), EvaluationDetail.error(CLIENT_NOT_READY, LDValue.of("x")).getValue()); + } + + @Test + public void getVariationIndex() { + assertEquals(1, EvaluationDetail.fromValue("x", 1, EvaluationReason.off()).getVariationIndex()); + assertEquals(NO_VARIATION, EvaluationDetail.fromValue("x", NO_VARIATION, EvaluationReason.off()).getVariationIndex()); + assertEquals(NO_VARIATION, EvaluationDetail.fromValue("x", -2, EvaluationReason.off()).getVariationIndex()); + assertEquals(NO_VARIATION, EvaluationDetail.error(CLIENT_NOT_READY, LDValue.of("x")).getVariationIndex()); + } + + @Test + public void getReason() { + assertEquals(EvaluationReason.fallthrough(), EvaluationDetail.fromValue("x", 1, EvaluationReason.fallthrough()).getReason()); + assertEquals(EvaluationReason.error(CLIENT_NOT_READY), + EvaluationDetail.error(CLIENT_NOT_READY, LDValue.of("x")).getReason()); + } + + @Test + public void isDefaultValue() { + assertFalse(EvaluationDetail.fromValue("x", 0, EvaluationReason.off()).isDefaultValue()); + assertFalse(EvaluationDetail.fromValue("x", 0, EvaluationReason.error(CLIENT_NOT_READY)).isDefaultValue()); + assertTrue(EvaluationDetail.fromValue("x", NO_VARIATION, EvaluationReason.error(CLIENT_NOT_READY)).isDefaultValue()); + assertTrue(EvaluationDetail.fromValue("x", -2, EvaluationReason.error(CLIENT_NOT_READY)).isDefaultValue()); + } + @Test public void equalInstancesAreEqual() { List>> testValues = new ArrayList<>(); @@ -15,7 +51,7 @@ public void equalInstancesAreEqual() { for (String value: new String[] { "a", "b" }) { List> equalValues = new ArrayList<>(); for (int i = 0; i < 2; i++) { - equalValues.add(new EvaluationDetail<>(reason, variation, value)); + equalValues.add(EvaluationDetail.fromValue(value, variation, reason)); } testValues.add(equalValues); } @@ -23,4 +59,26 @@ public void equalInstancesAreEqual() { } TestHelpers.doEqualityTests(testValues); } + + @Test + public void commonBooleanValuesAreInterned() { + for (Object value: new Object[] { LDValue.of(false), LDValue.of(true), Boolean.valueOf(false), Boolean.valueOf(true) }) { + for (int variationIndex = 0; variationIndex < 2; variationIndex++) { + for (EvaluationReason reason: new EvaluationReason[] { EvaluationReason.off(), EvaluationReason.fallthrough() }) { + EvaluationDetail detail1 = EvaluationDetail.fromValue(value, variationIndex, reason); + EvaluationDetail detail2 = EvaluationDetail.fromValue(value, variationIndex, reason); + assertEquals(value, detail1.getValue()); + assertEquals(variationIndex, detail1.getVariationIndex()); + assertEquals(reason, detail1.getReason()); + assertSame(detail1, detail2); + } + } + } + } + + @Test + public void simpleStringRepresentation() { + assertEquals("{x,0,OFF}", EvaluationDetail.fromValue("x", 0, EvaluationReason.off()).toString()); + assertEquals("{\"x\",-1,ERROR(CLIENT_NOT_READY)}", EvaluationDetail.error(CLIENT_NOT_READY, LDValue.of("x")).toString()); + } } From b810cd7885ad90aed309f8f9e5daee5550efcbd2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 24 Apr 2020 15:07:28 -0700 Subject: [PATCH 14/91] hide Gson in pom --- build.gradle | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/build.gradle b/build.gradle index 09378ad..540640a 100644 --- a/build.gradle +++ b/build.gradle @@ -50,7 +50,9 @@ ext.versions = [ ext.libraries = [:] dependencies { + // Gson will not be exposed in the pom - see below in pom.withXml block api "com.google.code.gson:gson:${versions.gson}" + testImplementation "org.hamcrest:hamcrest-all:1.3" testImplementation "junit:junit:4.12" } @@ -137,7 +139,19 @@ publishing { developerConnection = 'scm:git:ssh:git@github.com:launchdarkly/java-sdk-common.git' url = 'https://github.com/launchdarkly/java-sdk-common' } + } + + // We are deliberately hiding our dependencies in the pom. + // + // Currently the only dependency is Gson. While java-sdk-common does need Gson in order to work, + // the LaunchDarkly SDKs that use java-sdk-common have different strategies for packaging Gson. + // The Android SDK exposes it as a regular dependency; the Java server-side SDK embeds and shades + // Gson and does not expose it as a dependency. So we are leaving it up to the SDK to provide + // Gson in some way. + pom.withXml { + asNode().dependencies.forEach { it.value = "" } } + } } repositories { From 129d5b63e99aaf99f12d03accd661e418f33cc93 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 24 Apr 2020 15:10:22 -0700 Subject: [PATCH 15/91] doc additions --- CONTRIBUTING.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 082a7b5..0962fd3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,10 @@ To build the project and run all unit tests: ./gradlew test ``` -### Note on Java version and Android support +## Note on Java version and Android support This project is limited to Java 7 because it is used in both the LaunchDarkly server-side Java SDK and the LaunchDarkly Android SDK. Android only supports Java 8 to a limited degree, depending on both the version of the Android developer tools and the Android API version. Since this is a small code base, we have decided to use Java 7 for it despite the minor inconveniences that this causes in terms of syntax. + +## Note on dependencies + +This project's `build.gradle` contains special logic to exclude dependencies from `pom.xml`. This is because it is meant to be used as part of one of the LaunchDarkly SDKs, and the different SDKs have different strategies for either exposing or embedding these dependencies. Therefore, it is the responsibility of each SDK to provide its own dependency for any module that is actually required in order for `java-sdk-common` to work; currently that is only Gson. From 61366980b621e952cf777d04a551c860cf0382ce Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 27 Apr 2020 10:17:52 -0700 Subject: [PATCH 16/91] javadoc fixes --- src/main/java/com/launchdarkly/sdk/EvaluationDetail.java | 6 ++---- src/main/java/com/launchdarkly/sdk/EvaluationReason.java | 6 ++---- src/main/java/com/launchdarkly/sdk/LDUser.java | 5 ++--- src/main/java/com/launchdarkly/sdk/LDValue.java | 5 ++--- 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java index b2581ce..4d5bb17 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java @@ -1,8 +1,6 @@ package com.launchdarkly.sdk; -import com.launchdarkly.sdk.json.LDGson; import com.launchdarkly.sdk.json.JsonSerializable; -import com.launchdarkly.sdk.json.JsonSerialization; import static com.launchdarkly.sdk.Helpers.hashFrom; import static com.launchdarkly.sdk.Helpers.objectsEqual; @@ -13,8 +11,8 @@ *

* {@link EvaluationReason} can be converted to and from JSON in one of two ways: *

    - *
  1. With {@link JsonSerialization}. - *
  2. With Gson, if and only if you configure your Gson instance with {@link LDGson}. + *
  3. With {@link com.launchdarkly.sdk.json.JsonSerialization}. + *
  4. With Gson, if and only if you configure your Gson instance with {@link com.launchdarkly.sdk.json.LDGson}. *
* * @param the type of the wrapped value diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java index 9feab8e..605a231 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -1,9 +1,7 @@ package com.launchdarkly.sdk; import com.google.gson.annotations.JsonAdapter; -import com.launchdarkly.sdk.json.LDGson; import com.launchdarkly.sdk.json.JsonSerializable; -import com.launchdarkly.sdk.json.JsonSerialization; import java.util.Objects; @@ -20,8 +18,8 @@ * LaunchDarkly defines a standard JSON encoding for evaluation reasons, used in analytics events. * {@link EvaluationReason} can be converted to and from JSON in one of two ways: *
    - *
  1. With {@link JsonSerialization}. - *
  2. With Gson, if and only if you configure your Gson instance with {@link LDGson}. + *
  3. With {@link com.launchdarkly.sdk.json.JsonSerialization}. + *
  4. With Gson, if and only if you configure your Gson instance with {@link com.launchdarkly.sdk.json.LDGson}. *
*/ @JsonAdapter(EvaluationReasonTypeAdapter.class) diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index bb920ad..1603a27 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk; import com.google.gson.annotations.JsonAdapter; -import com.launchdarkly.sdk.json.LDGson; import com.launchdarkly.sdk.json.JsonSerializable; import com.launchdarkly.sdk.json.JsonSerialization; @@ -30,8 +29,8 @@ * LaunchDarkly defines a standard JSON encoding for user objects, used by the JavaScript SDK and also in analytics * events. {@link LDUser} can be converted to and from JSON in one of two ways: *
    - *
  1. With {@link JsonSerialization}. - *
  2. With Gson, if and only if you configure your Gson instance with {@link LDGson}. + *
  3. With {@link com.launchdarkly.sdk.json.JsonSerialization}. + *
  4. With Gson, if and only if you configure your Gson instance with {@link com.launchdarkly.sdk.json.LDGson}. *
*/ @JsonAdapter(LDUserTypeAdapter.class) diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index e63039a..787c669 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -3,7 +3,6 @@ import com.google.gson.Gson; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonWriter; -import com.launchdarkly.sdk.json.LDGson; import com.launchdarkly.sdk.json.JsonSerializable; import com.launchdarkly.sdk.json.JsonSerialization; import com.launchdarkly.sdk.json.SerializationException; @@ -35,8 +34,8 @@ *

* {@link LDValue} can be converted to and from JSON in one of three ways: *

    - *
  1. With {@link JsonSerialization}. - *
  2. With Gson, if and only if you configure your Gson instance with {@link LDGson}. + *
  3. With {@link com.launchdarkly.sdk.json.JsonSerialization}. + *
  4. With Gson, if and only if you configure your Gson instance with {@link com.launchdarkly.sdk.json.LDGson}. *
  5. With the {@link LDValue} methods {@link #toJsonString()} and {@link #parse(String)}. *
*/ From 54587383d129869b17481aa75ed3725fd2a21fc2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Apr 2020 10:06:34 -0700 Subject: [PATCH 17/91] add Jackson adapter (#9) --- build.gradle | 31 +++--- .../launchdarkly/sdk/EvaluationDetail.java | 7 +- .../launchdarkly/sdk/EvaluationReason.java | 7 +- .../java/com/launchdarkly/sdk/LDUser.java | 9 +- .../java/com/launchdarkly/sdk/LDValue.java | 9 +- .../com/launchdarkly/sdk/UserAttribute.java | 12 ++- .../sdk/json/JsonSerialization.java | 56 +++++++++++ .../com/launchdarkly/sdk/json/LDJackson.java | 99 +++++++++++++++++++ .../launchdarkly/sdk/json/package-info.java | 5 + ...EvaluationReasonJsonSerializationTest.java | 3 + .../sdk/json/JsonTestHelpers.java | 59 +++++++++-- .../sdk/json/LDUserJsonSerializationTest.java | 4 + .../sdk/json/ReflectiveFrameworksTest.java | 76 ++++++++++++++ .../UserAttributeJsonSerializationTest.java | 19 ++++ 14 files changed, 364 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/json/LDJackson.java create mode 100644 src/test/java/com/launchdarkly/sdk/json/ReflectiveFrameworksTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java diff --git a/build.gradle b/build.gradle index 540640a..845982b 100644 --- a/build.gradle +++ b/build.gradle @@ -44,21 +44,24 @@ ext { } ext.versions = [ - "gson": "2.7" + "gson": "2.7", + "jackson": "2.10.0" ] ext.libraries = [:] dependencies { - // Gson will not be exposed in the pom - see below in pom.withXml block - api "com.google.code.gson:gson:${versions.gson}" - + // Dependencies will not be exposed in the pom - see below in pom.withXml block + implementation "com.google.code.gson:gson:${versions.gson}" + implementation "com.fasterxml.jackson.core:jackson-core:${versions.jackson}" + implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" + testImplementation "org.hamcrest:hamcrest-all:1.3" testImplementation "junit:junit:4.12" } checkstyle { - configFile file("${project.rootDir}/checkstyle.xml") + configFile file("${project.rootDir}/checkstyle.xml") } // custom tasks for creating source/javadoc jars @@ -141,17 +144,19 @@ publishing { } } - // We are deliberately hiding our dependencies in the pom. - // - // Currently the only dependency is Gson. While java-sdk-common does need Gson in order to work, - // the LaunchDarkly SDKs that use java-sdk-common have different strategies for packaging Gson. - // The Android SDK exposes it as a regular dependency; the Java server-side SDK embeds and shades - // Gson and does not expose it as a dependency. So we are leaving it up to the SDK to provide - // Gson in some way. + // We are deliberately hiding our dependencies in the pom, for the following reasons: + // + // 1. Gson: While java-sdk-common does need Gson in order to work, the LaunchDarkly SDKs that use + // java-sdk-common have different strategies for packaging Gson. The Android SDK exposes it as a + // regular dependency; the Java server-side SDK embeds and shades Gson and does not expose it as + // a dependency. So we are leaving it up to the SDK to provide Gson in some way. + // + // 2. Jackson: The SDKs do not use, require, or embed Jackson; we provide the LDJackson class as + // a convenience for applications that do use Jackson. So we do not want it to be a transitive + // dependency. pom.withXml { asNode().dependencies.forEach { it.value = "" } } - } } repositories { diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java index 932420d..9aeaf04 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java @@ -10,10 +10,13 @@ * An object returned by the SDK's "variation detail" methods such as {@code boolVariationDetail}, * combining the result of a flag evaluation with an explanation of how it was calculated. *

- * {@link EvaluationReason} can be converted to and from JSON in one of two ways: + * {@link EvaluationReason} can be converted to and from JSON in any of these ways: *

    *
  1. With {@link com.launchdarkly.sdk.json.JsonSerialization}. - *
  2. With Gson, if and only if you configure your Gson instance with {@link com.launchdarkly.sdk.json.LDGson}. + *
  3. With Gson, if and only if you configure your {@code Gson} instance with + * {@link com.launchdarkly.sdk.json.LDGson}. + *
  4. With Jackson, if and only if you configure your {@code ObjectMapper} instance with + * {@link com.launchdarkly.sdk.json.LDJackson}. *
* * @param the type of the wrapped value diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java index 20056a8..fe4c004 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -16,10 +16,13 @@ * {@link #getKind()} or {@link #getRuleIndex()} to inspect the reason. *

* LaunchDarkly defines a standard JSON encoding for evaluation reasons, used in analytics events. - * {@link EvaluationReason} can be converted to and from JSON in one of two ways: + * {@link EvaluationReason} can be converted to and from JSON in any of these ways: *

    *
  1. With {@link com.launchdarkly.sdk.json.JsonSerialization}. - *
  2. With Gson, if and only if you configure your Gson instance with {@link com.launchdarkly.sdk.json.LDGson}. + *
  3. With Gson, if and only if you configure your {@code Gson} instance with + * {@link com.launchdarkly.sdk.json.LDGson}. + *
  4. With Jackson, if and only if you configure your {@code ObjectMapper} instance with + * {@link com.launchdarkly.sdk.json.LDJackson}. *
*/ @JsonAdapter(EvaluationReasonTypeAdapter.class) diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index 4ee8ddc..910fe95 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -26,10 +26,13 @@ * and Targeting users. *

* LaunchDarkly defines a standard JSON encoding for user objects, used by the JavaScript SDK and also in analytics - * events. {@link LDUser} can be converted to and from JSON in one of two ways: + * events. {@link LDUser} can be converted to and from JSON in any of these ways: *

    - *
  1. With {@link com.launchdarkly.sdk.json.JsonSerialization}. - *
  2. With Gson, if and only if you configure your Gson instance with {@link com.launchdarkly.sdk.json.LDGson}. + *
  3. With {@link JsonSerialization}. + *
  4. With Gson, if and only if you configure your {@code Gson} instance with + * {@link com.launchdarkly.sdk.json.LDGson}. + *
  5. With Jackson, if and only if you configure your {@code ObjectMapper} instance with + * {@link com.launchdarkly.sdk.json.LDJackson}. *
*/ @JsonAdapter(LDUserTypeAdapter.class) diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index 7f5e553..fa710eb 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -32,11 +32,14 @@ * applications that use Gson themselves. Second, Gson's array and object types are mutable, which can * cause concurrency risks. *

- * {@link LDValue} can be converted to and from JSON in one of three ways: + * {@link LDValue} can be converted to and from JSON in any of these ways: *

    - *
  1. With {@link com.launchdarkly.sdk.json.JsonSerialization}. - *
  2. With Gson, if and only if you configure your Gson instance with {@link com.launchdarkly.sdk.json.LDGson}. *
  3. With the {@link LDValue} methods {@link #toJsonString()} and {@link #parse(String)}. + *
  4. With {@link com.launchdarkly.sdk.json.JsonSerialization}. + *
  5. With Gson, if and only if you configure your {@code Gson} instance with + * {@link com.launchdarkly.sdk.json.LDGson}. + *
  6. With Jackson, if and only if you configure your {@code ObjectMapper} instance with + * {@link com.launchdarkly.sdk.json.LDJackson}. *
*/ @JsonAdapter(LDValueTypeAdapter.class) diff --git a/src/main/java/com/launchdarkly/sdk/UserAttribute.java b/src/main/java/com/launchdarkly/sdk/UserAttribute.java index 47d403e..6514851 100644 --- a/src/main/java/com/launchdarkly/sdk/UserAttribute.java +++ b/src/main/java/com/launchdarkly/sdk/UserAttribute.java @@ -4,6 +4,7 @@ import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.json.JsonSerializable; import java.io.IOException; import java.util.HashMap; @@ -21,7 +22,7 @@ * and Targeting users. */ @JsonAdapter(UserAttribute.UserAttributeTypeAdapter.class) -public final class UserAttribute { +public final class UserAttribute implements JsonSerializable { /** * Represents the user key attribute. */ @@ -187,7 +188,14 @@ public String toString() { static final class UserAttributeTypeAdapter extends TypeAdapter{ @Override public UserAttribute read(JsonReader reader) throws IOException { - return UserAttribute.forName(reader.nextString()); + // Unfortunately, JsonReader.nextString() does not actually enforce that the value is a string + switch (reader.peek()) { + case STRING: + return UserAttribute.forName(reader.nextString()); + default: + throw new IllegalStateException("expected string for UserAttribute"); + // IllegalStateException seems to be what Gson parsing methods normally use for wrong types + } } @Override diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index d046cec..c9908f4 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -1,7 +1,15 @@ package com.launchdarkly.sdk.json; import com.google.gson.Gson; +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 java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; /** * Helper methods for JSON serialization of SDK classes. @@ -22,6 +30,8 @@ * */ public abstract class JsonSerialization { + static final List> knownDeserializableClasses = new ArrayList<>(); + static final Gson gson = new Gson(); /** @@ -67,4 +77,50 @@ static T deserializeInternal(String json, Class objectClass) throws Seria throw new SerializationException(e); } } + + /** + * Internal method to return all of the classes that we should have a custom deserializer for. + *

+ * The reason for this method is for some JSON frameworks, such as Jackson, it is not possible to + * register a general deserializer for a base type like JsonSerializable and have it be called by + * the framework when someone wants to deserialize some concrete type descended from that base type. + * Instead, we must register a deserializer for each of the latter. + *

+ * Since the SDKs may define their own JsonSerializable types that are not in this common library, + * there is a reflection-based mechanism for discovering those: the SDK may define a class called + * com.launchdarkly.sdk.json.SdkSerializationExtensions, with a static method whose signature is + * the same as this method, and whatever it returns will be added to this return value. + *

+ * In the case of a base class like LDValue where the deserializer is for the base class (because + * application code does not know about the subclasses) and implements its own polymorphism, we + * should only list the base class. + * + * @return classes we should have a custom deserializer for + */ + static Iterable> getDeserializableClasses() { + synchronized (knownDeserializableClasses) { + if (knownDeserializableClasses.isEmpty()) { + knownDeserializableClasses.add(EvaluationReason.class); + knownDeserializableClasses.add(EvaluationDetail.class); + knownDeserializableClasses.add(LDUser.class); + knownDeserializableClasses.add(LDValue.class); + knownDeserializableClasses.add(UserAttribute.class); + + // Use reflection to find any additional classes provided by an SDK; if there are none or if + // this fails for any reason, don't worry about it + try { + Class sdkExtensionsClass = Class.forName("com.launchdarkly.sdk.json.SdkSerializationExtensions"); + Method method = sdkExtensionsClass.getMethod("getDeserializableClasses"); + @SuppressWarnings("unchecked") + Iterable> sdkClasses = + (Iterable>) method.invoke(null); + for (Class c: sdkClasses) { + knownDeserializableClasses.add(c); + } + } catch (Exception e) {} + } + } + + return knownDeserializableClasses; + } } diff --git a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java new file mode 100644 index 0000000..8390355 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java @@ -0,0 +1,99 @@ +package com.launchdarkly.sdk.json; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonLocation; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.TreeNode; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; + +import java.io.IOException; + +/** + * A helper class for interoperability with application code that uses + * Jackson. + *

+ * An application that wishes to use Jackson to serialize or deserialize classes from the SDK should + * configure its {@code ObjectMapper} instance as follows: + *


+ *     import com.launchdarkly.sdk.json.LDJackson;
+ *     
+ *     ObjectMapper mapper = new ObjectMapper();
+ *     mapper.registerModule(LDJackson.module());
+ * 
+ *

+ * This causes Jackson to use the correct JSON representation logic (the same that would be used by + * {@link JsonSerialization}) for any types that have the SDK's {@link JsonSerializable} marker + * interface, such as {@link LDUser} and {@link LDValue}, regardless of whether they are the + * top-level object being serialized or are contained in something else such as a collection. It + * does not affect Jackson's behavior for any other classes. + */ +public class LDJackson { + /** + * Returns a Jackson {@code Module} that defines the correct serialization and deserialization + * behavior for all LaunchDarkly SDK objects that implement {@link JsonSerializable}. + *


+   *     import com.launchdarkly.sdk.json.LDJackson;
+   *     
+   *     ObjectMapper mapper = new ObjectMapper();
+   *     mapper.registerModule(LDJackson.module());
+   * 
+ * @return a {@code Module} + */ + public static Module module() { + SimpleModule module = new SimpleModule(LDJackson.class.getName()); + module.addSerializer(JsonSerializable.class, LDJacksonSerializer.INSTANCE); + for (Class c: JsonSerialization.getDeserializableClasses()) { + @SuppressWarnings("unchecked") + Class cjs = (Class)c; + module.addDeserializer(cjs, new LDJacksonDeserializer<>(cjs)); + } + return module; + } + + private static class LDJacksonSerializer extends JsonSerializer { + static final LDJacksonSerializer INSTANCE = new LDJacksonSerializer(); + + @Override + public void serialize(JsonSerializable value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + if (value == null) { + gen.writeNull(); + } else { + String json = JsonSerialization.serializeInternal(value); + gen.writeRawValue(json); + } + } + } + + private static class LDJacksonDeserializer extends JsonDeserializer { + private final Class objectClass; + + LDJacksonDeserializer(Class objectClass) { + this.objectClass = objectClass; + } + + @Override + public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException { + // This implementation is inefficient because our internal Gson instance can't use Jackson's + // streaming parser directly; instead we have to read the next JSON value, convert it to a + // string, and then ask our JsonSerialization to parse it back from a string. + JsonLocation loc = p.getCurrentLocation(); + TreeNode jsonTree = p.readValueAsTree(); + String jsonString = jsonTree.toString(); + try { + return JsonSerialization.deserialize(jsonString, objectClass); + } catch (SerializationException e) { + throw new JsonParseException(p, "invalid JSON encoding for " + objectClass.getSimpleName(), loc, e); + } + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/json/package-info.java b/src/main/java/com/launchdarkly/sdk/json/package-info.java index aa49ddd..d87f652 100644 --- a/src/main/java/com/launchdarkly/sdk/json/package-info.java +++ b/src/main/java/com/launchdarkly/sdk/json/package-info.java @@ -1,4 +1,9 @@ /** * Helper classes and methods for interoperability with JSON. + *

+ * This package provides a simple mechanism for converting SDK objects to and from JSON as a string + * ({@link com.launchdarkly.sdk.json.JsonSerialization}), and also adapters for making the SDK types + * serialize and deserialize correctly when used with Gson + * ({@link com.launchdarkly.sdk.json.LDGson}) or Jackson ({@link com.launchdarkly.sdk.json.LDJackson}). */ package com.launchdarkly.sdk.json; diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java index f907e4f..aa85955 100644 --- a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java @@ -4,6 +4,7 @@ import org.junit.Test; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; @@ -20,6 +21,8 @@ public void reasonJsonSerializations() throws Exception { "{\"kind\":\"PREREQUISITE_FAILED\",\"prerequisiteKey\":\"key\"}"); verifySerializeAndDeserialize(EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND), "{\"kind\":\"ERROR\",\"errorKind\":\"FLAG_NOT_FOUND\"}"); + + verifyDeserializeInvalidJson(EvaluationReason.class, "3"); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java index 25b7a85..b7b6fce 100644 --- a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java @@ -1,10 +1,15 @@ package com.launchdarkly.sdk.json; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.launchdarkly.sdk.LDValue; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; @SuppressWarnings("javadoc") public abstract class JsonTestHelpers { @@ -14,6 +19,16 @@ public abstract class JsonTestHelpers { // of Gson interoperability using the shaded SDK jar. But the tests in this project still prove // that the adapters work correctly if Gson actually uses them. + public static Gson configureGson() { + return new GsonBuilder().registerTypeAdapterFactory(LDGson.typeAdapters()).create(); + } + + public static ObjectMapper configureJacksonMapper() { + ObjectMapper jacksonMapper = new ObjectMapper(); + jacksonMapper.registerModule(LDJackson.module()); + return jacksonMapper; + } + public static void verifySerializeAndDeserialize(T instance, String expectedJsonString) throws Exception { verifySerialize(instance, expectedJsonString); verifyDeserialize(instance, expectedJsonString); @@ -22,23 +37,53 @@ public static void verifySerializeAndDeserialize(T public static void verifySerialize(T instance, String expectedJsonString) throws Exception { // All subclasses of Gson's JsonElement implement deep equality for equals(). So does our own LDValue, // but since some of our tests are testing LDValue itself, we can't assume that its behavior is correct. - assertEquals(parseElement(expectedJsonString), parseElement(JsonSerialization.serialize(instance))); + assertJsonEquals(expectedJsonString, JsonSerialization.serialize(instance)); + + assertJsonEquals(expectedJsonString, configureGson().toJson(instance)); - Gson gson = new GsonBuilder().registerTypeAdapterFactory(LDGson.typeAdapters()).create(); - assertEquals(parseElement(expectedJsonString), parseElement(gson.toJson(instance))); + assertJsonEquals(expectedJsonString, configureJacksonMapper().writeValueAsString(instance)); } @SuppressWarnings("unchecked") public static void verifyDeserialize(T instance, String expectedJsonString) throws Exception { - T instance1 = JsonSerialization.deserialize(expectedJsonString, (Class)instance.getClass()); + // Special handling here because in real life you wouldn't be trying to deserialize something as for + // instance LDValueNumber, because those subclasses aren't public; you have to refer to the base class. + Class objectClass = (Class)instance.getClass(); + if (LDValue.class.isAssignableFrom(objectClass)) { + objectClass = (Class)LDValue.class; + } + + T instance1 = JsonSerialization.deserialize(expectedJsonString, objectClass); assertEquals(instance, instance1); - Gson gson = new GsonBuilder().registerTypeAdapterFactory(LDGson.typeAdapters()).create(); - T instance2 = gson.fromJson(expectedJsonString, (Class)instance.getClass()); + T instance2 = configureGson().fromJson(expectedJsonString, objectClass); assertEquals(instance, instance2); + + T instance3 = configureJacksonMapper().readValue(expectedJsonString, objectClass); + assertEquals(instance, instance3); } - static JsonElement parseElement(String jsonString) { + public static void verifyDeserializeInvalidJson(Class objectClass, String invalidJsonString) + throws Exception { + try { + JsonSerialization.deserialize(invalidJsonString, objectClass); + fail("expected SerializationException"); + } catch (SerializationException e) {} + try { + configureGson().fromJson(invalidJsonString, objectClass); + fail("expected JsonParseException from Gson"); + } catch (JsonParseException e) {} + try { + configureJacksonMapper().readValue(invalidJsonString, objectClass); + fail("expected JsonProcessingException from Jackson"); + } catch (JsonProcessingException e) {} + } + + public static void assertJsonEquals(String expectedJsonString, String actualJsonString) { + assertEquals(parseElement(expectedJsonString), parseElement(actualJsonString)); + } + + public static JsonElement parseElement(String jsonString) { return JsonSerialization.gson.fromJson(jsonString, JsonElement.class); } } diff --git a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java index 7dd27d1..f7b90f5 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java @@ -5,6 +5,7 @@ import org.junit.Test; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; @SuppressWarnings("javadoc") @@ -13,6 +14,9 @@ public class LDUserJsonSerializationTest { public void minimalJsonEncoding() throws Exception { LDUser user = new LDUser("userkey"); verifySerializeAndDeserialize(user, "{\"key\":\"userkey\"}"); + + verifyDeserializeInvalidJson(LDUser.class, "3"); + verifyDeserializeInvalidJson(LDUser.class, "{\"key\":\"userkey\",\"name\":3"); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/json/ReflectiveFrameworksTest.java b/src/test/java/com/launchdarkly/sdk/json/ReflectiveFrameworksTest.java new file mode 100644 index 0000000..b1f0b99 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/ReflectiveFrameworksTest.java @@ -0,0 +1,76 @@ +package com.launchdarkly.sdk.json; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.launchdarkly.sdk.LDValue; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static com.launchdarkly.sdk.json.JsonTestHelpers.assertJsonEquals; +import static com.launchdarkly.sdk.json.JsonTestHelpers.configureGson; +import static com.launchdarkly.sdk.json.JsonTestHelpers.configureJacksonMapper; +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") +public class ReflectiveFrameworksTest { + // Test classes like LDValueJsonSerializationTest already cover using all available JSON + // frameworks to serialize and deserialize instances of our classes. This one tests the + // ability of Gson and Jackson, when properly configured, to get the right serialization + // or deserialization reflectively when we do not specify the desired class up front - + // that is, when one of our types is used inside another data structure. + // + // Since we've already verified the serializations for each of our types separately, we + // can just use LDValue here and assume that it would work the same for other types. + + private static final LDValue TOP_LEVEL_VALUE = LDValue.of("x"); + private static final String EXPECTED_JSON = + "{\"topLevelValue\":\"x\",\"mapOfValues\":{\"a\":1,\"b\":[2,3]}}"; + + @Test + public void gsonSerializesTypeContainingOurType() { + ObjectContainingValues o = new ObjectContainingValues(TOP_LEVEL_VALUE, makeMapOfValues()); + assertJsonEquals(EXPECTED_JSON, configureGson().toJson(o)); + } + + @Test + public void gsonDeserializesTypeContainingOurTypes() { + ObjectContainingValues o = configureGson().fromJson(EXPECTED_JSON, ObjectContainingValues.class); + assertEquals(TOP_LEVEL_VALUE, o.topLevelValue); + assertEquals(makeMapOfValues(), o.mapOfValues); + } + + @Test + public void jacksonSerializesTypeContainingOurType() throws Exception { + ObjectContainingValues o = new ObjectContainingValues(TOP_LEVEL_VALUE, makeMapOfValues()); + assertJsonEquals(EXPECTED_JSON, configureJacksonMapper().writeValueAsString(o)); + } + + @Test + public void jacksonDeserializesTypeContainingOurTypes() throws Exception { + ObjectContainingValues o = configureJacksonMapper().readValue(EXPECTED_JSON, ObjectContainingValues.class); + assertEquals(TOP_LEVEL_VALUE, o.topLevelValue); + assertEquals(makeMapOfValues(), o.mapOfValues); + } + + private static Map makeMapOfValues() { + Map m = new HashMap<>(); + m.put("a", LDValue.of(1)); + m.put("b", LDValue.buildArray().add(2).add(3).build()); + return m; + } + + private static final class ObjectContainingValues { + public LDValue topLevelValue; + public Map mapOfValues; + + @JsonCreator + public ObjectContainingValues(@JsonProperty("topLevelValue") LDValue topLevelValue, + @JsonProperty("mapOfValues") Map mapOfValues) { + this.topLevelValue = topLevelValue; + this.mapOfValues = mapOfValues; + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java new file mode 100644 index 0000000..70a8eab --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java @@ -0,0 +1,19 @@ +package com.launchdarkly.sdk.json; + +import com.launchdarkly.sdk.UserAttribute; + +import org.junit.Test; + +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; + +@SuppressWarnings("javadoc") +public class UserAttributeJsonSerializationTest { + @Test + public void userAttributeJsonSerializations() throws Exception { + verifySerializeAndDeserialize(UserAttribute.NAME, "\"name\""); + verifySerializeAndDeserialize(UserAttribute.forName("custom-attr"), "\"custom-attr\""); + + verifyDeserializeInvalidJson(UserAttribute.class, "3"); + } +} From 5635a1d4292a49aaa182f896b530fcfe4def57ea Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Apr 2020 11:23:51 -0700 Subject: [PATCH 18/91] more predictable exception behavior for LDValue.parse() --- src/main/java/com/launchdarkly/sdk/LDValue.java | 17 ++++++++--------- .../java/com/launchdarkly/sdk/LDValueTest.java | 13 +++++++++++++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index fa710eb..5a4adad 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -163,10 +163,13 @@ public static ObjectBuilder buildObject() { * Parses an LDValue from a JSON representation. *

* This convenience method is equivalent to using {@link JsonSerialization#deserialize(String, Class)} - * with the {@code LDValue} class, except for two things: 1. you do not have to provide the class - * parameter; 2. parsing errors are thrown as an unchecked {@code RuntimeException}, rather than a - * checked {@link SerializationException}, making this method somewhat more convenient in cases such - * as test code where explicit error handling is less important. + * with the {@code LDValue} class, except for two things: + *

+ * 1. You do not have to provide the class parameter. + *

+ * 2. Parsing errors are thrown as an unchecked {@code RuntimeException} that wraps the checked + * {@link SerializationException}, making this method somewhat more convenient in cases such as + * test code where explicit error handling is less important. * * @param json a JSON string * @return an LDValue @@ -175,11 +178,7 @@ public static LDValue parse(String json) { try { return JsonSerialization.deserialize(json, LDValue.class); } catch (SerializationException e) { - if (e.getCause() instanceof RuntimeException) { - throw (RuntimeException)e.getCause(); - } else { - throw new RuntimeException(e.getCause()); - } + throw new RuntimeException(e); } } diff --git a/src/test/java/com/launchdarkly/sdk/LDValueTest.java b/src/test/java/com/launchdarkly/sdk/LDValueTest.java index c4395f1..f386eab 100644 --- a/src/test/java/com/launchdarkly/sdk/LDValueTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueTest.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk; +import com.launchdarkly.sdk.json.SerializationException; + import org.junit.Test; import java.util.ArrayList; @@ -10,6 +12,8 @@ import static com.launchdarkly.sdk.TestHelpers.listFromIterable; import static java.util.Arrays.asList; import static java.util.Collections.addAll; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; @@ -373,4 +377,13 @@ private void testTypeConversion(LDValue.Converter converter, T[] values, } assertEquals(objectValue, converter.objectFrom(map)); } + + @Test + public void parseThrowsRuntimeExceptionForMalformedJson() { + try { + LDValue.parse("{"); + } catch (RuntimeException e) { + assertThat(e.getCause(), instanceOf(SerializationException.class)); + } + } } From 21e91672f3a0be78310ce13a2b3e1d74896305db Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Apr 2020 13:27:20 -0700 Subject: [PATCH 19/91] fix the JSON behavior of EvaluationDetail (#10) --- .../launchdarkly/sdk/EvaluationDetail.java | 9 ++ .../EvaluationDetailTypeAdapterFactory.java | 92 +++++++++++++++++++ .../sdk/EvaluationReasonTypeAdapter.java | 4 + .../sdk/json/JsonSerialization.java | 15 +++ .../com/launchdarkly/sdk/json/LDGson.java | 14 +-- .../com/launchdarkly/sdk/json/LDJackson.java | 5 + ...EvaluationDetailJsonSerializationTest.java | 30 ++++++ .../sdk/json/ReflectiveFrameworksTest.java | 46 +++++++++- 8 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java create mode 100644 src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java index 9aeaf04..fcac4b1 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk; +import com.google.gson.annotations.JsonAdapter; import com.launchdarkly.sdk.json.JsonSerializable; import java.util.ArrayList; @@ -19,8 +20,16 @@ * {@link com.launchdarkly.sdk.json.LDJackson}. * * + * Note: There is currently a limitation regarding deserialization for generic types. + * If you use Gson, you must pass a `TypeToken` to specify the runtime type of + * {@code EvaluationDetail}, or else it will assume that `T` is `LDValue`. If you use either + * {@code JsonSerialization} or Jackson, there is no way to specify the runtime type and you + * will always get an {@code EvaluationDetail}. That is only for deserialization; + * serialization will always use the correct value type. + * * @param the type of the wrapped value */ +@JsonAdapter(EvaluationDetailTypeAdapterFactory.class) public final class EvaluationDetail implements JsonSerializable { /** * If {@link #getVariationIndex()} is equal to this constant, it means no variation was chosen diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java new file mode 100644 index 0000000..b6d66c7 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java @@ -0,0 +1,92 @@ +package com.launchdarkly.sdk; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; + +final class EvaluationDetailTypeAdapterFactory implements TypeAdapterFactory { + // This needs to be a TypeAdapterFactory rather than a TypeAdapter because in order to deserialize + // an instance, we need to know what the generic type parameter for the value is. + + @SuppressWarnings("unchecked") + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + if (type.getType() instanceof ParameterizedType) { + ParameterizedType pt = (ParameterizedType)type.getType(); + Type[] args = pt.getActualTypeArguments(); + if (args.length == 1) { + return (TypeAdapter)new EvaluationDetailTypeAdapter(gson, args[0]); + } + } + // When the generic type is unknown (EvaluationDetail), we'll treat it as LDValue. + return (TypeAdapter)new EvaluationDetailTypeAdapter(gson, LDValue.class); + } + + static final class EvaluationDetailTypeAdapter extends TypeAdapter> { + private final Gson gson; + private final Type valueType; + + EvaluationDetailTypeAdapter(Gson gson, Type valueType) { + this.gson = gson; + this.valueType = valueType; + } + + @Override + public void write(JsonWriter out, EvaluationDetail value) throws IOException { + out.beginObject(); + + out.name("value"); + if (value.getValue() == null) { + out.nullValue(); + } else { + gson.toJson(value.getValue(), Object.class, out); + } + if (!value.isDefaultValue()) { + out.name("variationIndex"); + out.value(value.getVariationIndex()); + } + out.name("reason"); + gson.toJson(value.getReason(), EvaluationReason.class, out); + + out.endObject(); + } + + @Override + public EvaluationDetail read(JsonReader in) throws IOException { + T value = null; + int variation = EvaluationDetail.NO_VARIATION; + EvaluationReason reason = null; + + in.beginObject(); + + while (in.peek() != JsonToken.END_OBJECT) { + String key = in.nextName(); + switch (key) { + case "value": + value = gson.fromJson(in, valueType); + break; + case "variationIndex": + variation = in.nextInt(); + break; + case "reason": + reason = EvaluationReasonTypeAdapter.parse(in); + break; + default: + in.skipValue(); + } + } + in.endObject(); + + return EvaluationDetail.fromValue(value, variation, reason); + } + + } +} diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java index 762ab54..19e1882 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java @@ -11,6 +11,10 @@ final class EvaluationReasonTypeAdapter extends TypeAdapter { @Override public EvaluationReason read(JsonReader reader) throws IOException { + return parse(reader); + } + + static EvaluationReason parse(JsonReader reader) throws IOException { EvaluationReason.Kind kind = null; int ruleIndex = -1; String ruleId = null; diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index c9908f4..ae35f0e 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.UserAttribute; import java.lang.reflect.Method; +import java.lang.reflect.Type; import java.util.ArrayList; import java.util.List; @@ -58,6 +59,11 @@ static String serializeInternal(Object instance) { *

* This is only usable for classes that have the {@link JsonSerializable} marker interface, * indicating that the SDK knows how to serialize them. + *

+ * The current implementation is limited in its ability to handle generic types. Currently, the only + * such type defined by the SDKs is {@link com.launchdarkly.sdk.EvaluationDetail}. You can serialize + * any {@code EvaluationDetail} instance and it will represent the {@code T} value correctly, but + * when deserializing, you will always get {@code EvaluationDetail}. * * @param class of the object being deserialized * @param json the object's JSON encoding as a string @@ -77,6 +83,15 @@ static T deserializeInternal(String json, Class objectClass) throws Seria throw new SerializationException(e); } } + + // Used internally from LDGson + static T deserializeInternalGson(String json, Type objectType) throws SerializationException { + try { + return gson.fromJson(json, objectType); + } catch (Exception e) { + throw new SerializationException(e); + } + } /** * Internal method to return all of the classes that we should have a custom deserializer for. diff --git a/src/main/java/com/launchdarkly/sdk/json/LDGson.java b/src/main/java/com/launchdarkly/sdk/json/LDGson.java index ecfda0f..a5bc348 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDGson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDGson.java @@ -12,6 +12,7 @@ import com.launchdarkly.sdk.LDValue; import java.io.IOException; +import java.lang.reflect.Type; /** * A helper class for interoperability with application code that uses Gson. @@ -84,7 +85,7 @@ private static class LDTypeAdapterFactory implements TypeAdapterFactory { @Override public TypeAdapter create(Gson gson, TypeToken type) { if (JsonSerializable.class.isAssignableFrom(type.getRawType())) { - return new LDTypeAdapter(gson, type); + return new LDTypeAdapter(gson, type.getType()); } return null; } @@ -92,12 +93,11 @@ public TypeAdapter create(Gson gson, TypeToken type) { private static class LDTypeAdapter extends TypeAdapter { private final Gson gson; - private final Class objectClass; + private final Type objectType; - @SuppressWarnings("unchecked") - LDTypeAdapter(Gson gson, TypeToken type) { + LDTypeAdapter(Gson gson, Type objectType) { this.gson = gson; - this.objectClass = (Class)type.getRawType(); + this.objectType = objectType; } @Override @@ -114,7 +114,9 @@ public T read(JsonReader in) throws IOException { JsonElement jsonTree = gson.fromJson(in, JsonElement.class); String jsonString = gson.toJson(jsonTree); try { - return JsonSerialization.deserializeInternal(jsonString, objectClass); + // Calling the Gson overload that takes a Type rather than a Class (even though a Class *is* a + // Type) allows it to take generic type parameters into account for EvaluationDetail. + return JsonSerialization.deserializeInternalGson(jsonString, objectType); } catch (SerializationException e) { throw new JsonParseException(e.getCause()); } diff --git a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java index 8390355..ed19665 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java @@ -35,6 +35,11 @@ * interface, such as {@link LDUser} and {@link LDValue}, regardless of whether they are the * top-level object being serialized or are contained in something else such as a collection. It * does not affect Jackson's behavior for any other classes. + *

+ * The current implementation is limited in its ability to handle generic types. Currently, the only + * such type defined by the SDKs is {@link com.launchdarkly.sdk.EvaluationDetail}. You can serialize + * any {@code EvaluationDetail} instance and it will represent the {@code T} value correctly, but + * when deserializing, you will always get {@code EvaluationDetail}. */ public class LDJackson { /** diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java new file mode 100644 index 0000000..ebd3d6c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java @@ -0,0 +1,30 @@ +package com.launchdarkly.sdk.json; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; + +import org.junit.Test; + +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.CLIENT_NOT_READY; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; + +@SuppressWarnings("javadoc") +public class EvaluationDetailJsonSerializationTest { + @Test + public void detailJsonSerializations() throws Exception { + verifySerializeAndDeserialize(EvaluationDetail.fromValue(LDValue.of("x"), 1, EvaluationReason.off()), + "{\"value\":\"x\",\"variationIndex\":1,\"reason\":{\"kind\":\"OFF\"}}"); + + // variationIndex of NO_VARIATION is omitted, rather than serialized as -1 + verifySerializeAndDeserialize( + EvaluationDetail.fromValue(LDValue.of("x"), NO_VARIATION, EvaluationReason.error(CLIENT_NOT_READY)), + "{\"value\":\"x\",\"reason\":{\"kind\":\"ERROR\",\"errorKind\":\"CLIENT_NOT_READY\"}}"); + + // Due to how generic types work in Gson, simply calling Gson.fromJson> will *not* + // use any custom deserialization for type T; it will behave as if T were LDValue. However, it should + // correctly pick up the type signature if you deserialize an object that contains such a value. That + // scenario is covered in ReflectiveFrameworksTest. + } +} diff --git a/src/test/java/com/launchdarkly/sdk/json/ReflectiveFrameworksTest.java b/src/test/java/com/launchdarkly/sdk/json/ReflectiveFrameworksTest.java index b1f0b99..df3cc0d 100644 --- a/src/test/java/com/launchdarkly/sdk/json/ReflectiveFrameworksTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/ReflectiveFrameworksTest.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; import org.junit.Test; @@ -23,15 +25,19 @@ public class ReflectiveFrameworksTest { // that is, when one of our types is used inside another data structure. // // Since we've already verified the serializations for each of our types separately, we - // can just use LDValue here and assume that it would work the same for other types. + // don't need to repeat these tests for all of them. We will just use LDValue to stand in + // for all the non-generic types, and EvaluationDetail as a generic type. private static final LDValue TOP_LEVEL_VALUE = LDValue.of("x"); private static final String EXPECTED_JSON = - "{\"topLevelValue\":\"x\",\"mapOfValues\":{\"a\":1,\"b\":[2,3]}}"; + "{\"topLevelValue\":\"x\",\"mapOfValues\":{\"a\":1,\"b\":[2,3]}," + + "\"detailValue\":{\"value\":1000,\"variationIndex\":1,\"reason\":{\"kind\":\"OFF\"}}," + + "\"stringDetailValue\":{\"value\":\"x\",\"variationIndex\":1,\"reason\":{\"kind\":\"OFF\"}}}"; @Test public void gsonSerializesTypeContainingOurType() { - ObjectContainingValues o = new ObjectContainingValues(TOP_LEVEL_VALUE, makeMapOfValues()); + ObjectContainingValues o = new ObjectContainingValues( + TOP_LEVEL_VALUE, makeMapOfValues(), makeDetailValue(), makeStringDetailValue()); assertJsonEquals(EXPECTED_JSON, configureGson().toJson(o)); } @@ -40,11 +46,14 @@ public void gsonDeserializesTypeContainingOurTypes() { ObjectContainingValues o = configureGson().fromJson(EXPECTED_JSON, ObjectContainingValues.class); assertEquals(TOP_LEVEL_VALUE, o.topLevelValue); assertEquals(makeMapOfValues(), o.mapOfValues); + assertEquals(makeDetailValue(), o.detailValue); + assertEquals(makeStringDetailValue(), o.stringDetailValue); } @Test public void jacksonSerializesTypeContainingOurType() throws Exception { - ObjectContainingValues o = new ObjectContainingValues(TOP_LEVEL_VALUE, makeMapOfValues()); + ObjectContainingValues o = new ObjectContainingValues( + TOP_LEVEL_VALUE, makeMapOfValues(),makeDetailValue(), makeStringDetailValue()); assertJsonEquals(EXPECTED_JSON, configureJacksonMapper().writeValueAsString(o)); } @@ -53,6 +62,16 @@ public void jacksonDeserializesTypeContainingOurTypes() throws Exception { ObjectContainingValues o = configureJacksonMapper().readValue(EXPECTED_JSON, ObjectContainingValues.class); assertEquals(TOP_LEVEL_VALUE, o.topLevelValue); assertEquals(makeMapOfValues(), o.mapOfValues); + assertEquals(makeDetailValue(), o.detailValue); + + // The current implementation of the Jackson adapter cannot see generic type parameters; the + // EvaluationDetail field will be deserialized as EvaluationDetail. This limitation + // is documented in EvaluationDetail and LDJackson. + //assertEquals(makeStringDetailValue(), o.stringDetailValue); + assertEquals(EvaluationDetail.fromValue( + LDValue.of(makeStringDetailValue().getValue()), + makeStringDetailValue().getVariationIndex(), makeStringDetailValue().getReason()), + o.stringDetailValue); } private static Map makeMapOfValues() { @@ -62,15 +81,32 @@ private static Map makeMapOfValues() { return m; } + private static EvaluationDetail makeDetailValue() { + return EvaluationDetail.fromValue(LDValue.of(1000), 1, EvaluationReason.off()); + } + + private static EvaluationDetail makeStringDetailValue() { + // What we're testing here is that deserializing with a target type of EvaluationDetail, + // when that type signature is knowable via reflection, causes it to parse the value property as + // a String rather than an LDValue. + return EvaluationDetail.fromValue("x", 1, EvaluationReason.off()); + } + private static final class ObjectContainingValues { public LDValue topLevelValue; public Map mapOfValues; + public EvaluationDetail detailValue; + public EvaluationDetail stringDetailValue; @JsonCreator public ObjectContainingValues(@JsonProperty("topLevelValue") LDValue topLevelValue, - @JsonProperty("mapOfValues") Map mapOfValues) { + @JsonProperty("mapOfValues") Map mapOfValues, + @JsonProperty("detailValue") EvaluationDetail detailValue, + @JsonProperty("stringDetailValue") EvaluationDetail stringDetailValue) { this.topLevelValue = topLevelValue; this.mapOfValues = mapOfValues; + this.detailValue = detailValue; + this.stringDetailValue = stringDetailValue; } } } From 7c29e7b0435a3e3ed406abd55822b24858ba397c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Apr 2020 16:34:18 -0700 Subject: [PATCH 20/91] maximize and enforce test coverage (#12) --- .circleci/config.yml | 18 ++ CONTRIBUTING.md | 10 + build.gradle | 56 ++++ .../launchdarkly/sdk/EvaluationReason.java | 2 +- .../sdk/EvaluationReasonTypeAdapter.java | 20 +- .../java/com/launchdarkly/sdk/Helpers.java | 26 ++ .../java/com/launchdarkly/sdk/LDUser.java | 3 +- .../launchdarkly/sdk/LDUserTypeAdapter.java | 33 +- .../java/com/launchdarkly/sdk/LDValue.java | 14 +- .../launchdarkly/sdk/LDValueTypeAdapter.java | 2 + .../sdk/json/JsonSerialization.java | 4 + .../com/launchdarkly/sdk/json/LDJackson.java | 9 +- .../sdk/EvaluationReasonTest.java | 65 +++- .../java/com/launchdarkly/sdk/LDUserTest.java | 47 ++- .../launchdarkly/sdk/LDValueArrayTest.java | 149 +++++++++ .../launchdarkly/sdk/LDValueObjectTest.java | 148 +++++++++ .../com/launchdarkly/sdk/LDValueTest.java | 298 +++++------------- .../com/launchdarkly/sdk/TestHelpers.java | 26 +- .../launchdarkly/sdk/UserAttributeTest.java | 8 +- ...EvaluationDetailJsonSerializationTest.java | 4 + ...EvaluationReasonJsonSerializationTest.java | 8 + .../sdk/json/LDUserJsonSerializationTest.java | 29 +- .../json/LDValueJsonSerializationTest.java | 2 + 23 files changed, 715 insertions(+), 266 deletions(-) create mode 100644 src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/LDValueObjectTest.java diff --git a/.circleci/config.yml b/.circleci/config.yml index d329a1e..9e15d94 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,6 +25,7 @@ workflows: - test-linux: name: Java 11 - Linux - OpenJDK docker-image: circleci/openjdk:11 + with-coverage: true requires: - build-linux - build-test-windows: @@ -50,6 +51,9 @@ jobs: parameters: docker-image: type: string + with-coverage: + type: boolean + default: false docker: - image: <> steps: @@ -59,6 +63,15 @@ jobs: at: build - run: java -version - run: ./gradlew test + - when: + condition: <> + steps: + - run: + name: Generate test coverage report + command: ./gradlew jacocoTestReport + - run: + name: Enforce test coverage + command: ./gradlew jacocoTestCoverageVerification - run: name: Save test results command: | @@ -69,6 +82,11 @@ jobs: path: ~/junit - store_artifacts: path: ~/junit + - when: + condition: <> + steps: + - store_artifacts: + path: build/reports/jacoco build-test-windows: executor: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0962fd3..02aa5f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,6 +53,16 @@ To build the project and run all unit tests: This project is limited to Java 7 because it is used in both the LaunchDarkly server-side Java SDK and the LaunchDarkly Android SDK. Android only supports Java 8 to a limited degree, depending on both the version of the Android developer tools and the Android API version. Since this is a small code base, we have decided to use Java 7 for it despite the minor inconveniences that this causes in terms of syntax. +## Code coverage + +It is important to keep unit test coverage as close to 100% as possible in this project, since the SDK projects will not exercise every `java-sdk-common` method in their own unit tests. + +Sometimes a gap in coverage is unavoidable, usually because the compiler requires us to provide a code path for some condition that in practice can't happen and can't be tested, or because of a known issue with the code coverage tool. Please handle all such cases as follows: + +* Mark the code with an explanatory comment beginning with "COVERAGE:". +* Run the code coverage task with `./gradlew jacocoTestCoverageVerification`. It should fail and indicate how many lines of missed coverage exist in the method you modified. +* Add an item in the `knownMissedLinesForMethods` map in `build.gradle` that specifies that number of missed lines for that method signature. + ## Note on dependencies This project's `build.gradle` contains special logic to exclude dependencies from `pom.xml`. This is because it is meant to be used as part of one of the LaunchDarkly SDKs, and the different SDKs have different strategies for either exposing or embedding these dependencies. Therefore, it is the responsibility of each SDK to provide its own dependency for any module that is actually required in order for `java-sdk-common` to work; currently that is only Gson. diff --git a/build.gradle b/build.gradle index 845982b..65bf1c9 100644 --- a/build.gradle +++ b/build.gradle @@ -10,6 +10,7 @@ plugins { id "java" id "java-library" id "checkstyle" + id "jacoco" id "signing" id "maven-publish" id "de.marcphilipp.nexus-publish" version "0.3.0" @@ -98,6 +99,61 @@ test { } } +jacocoTestReport { // code coverage report + reports { + xml.enabled + csv.enabled true + html.enabled true + } +} + +jacocoTestCoverageVerification { + // See notes in CONTRIBUTING.md on code coverage. Unfortunately we can't configure line-by-line code + // coverage overrides within the source code itself, because Jacoco operates on bytecode. + violationRules { rules -> + def knownMissedLinesForMethods = [ + // The key for each of these items is the complete method signature minus the "com.launchdarkly.sdk." prefix. + "EvaluationReason.error(com.launchdarkly.sdk.EvaluationReason.ErrorKind)": 1, + "EvaluationReasonTypeAdapter.parse(com.google.gson.stream.JsonReader)": 1, + "EvaluationDetailTypeAdapterFactory.EvaluationDetailTypeAdapter.read(com.google.gson.stream.JsonReader)": 1, + "Helpers.Helpers()": 1, // abstract class constructor - known issue with Jacoco + "LDValue.equals(java.lang.Object)": 1, + "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)": 3, + "json.JsonSerialization.JsonSerialization()": 1, // abstract class constructor + "json.JsonSerialization.getDeserializableClasses()": -1, + "json.LDGson.LDGson()": 1, // abstract class constructor + "json.LDJackson.LDJackson()": 1 // abstract class constructor + ] + + knownMissedLinesForMethods.each { partialSignature, maxMissedLines -> + if (maxMissedLines > 0) { // < 0 means skip entire method + rules.rule { + element = "METHOD" + includes = [ "com.launchdarkly.sdk." + partialSignature ] + limit { + counter = "LINE" + value = "MISSEDCOUNT" + maximum = maxMissedLines + } + } + } + } + + // General rule that we should expect 100% test coverage; exclude any methods that have overrides above + rule { + element = "METHOD" + limit { + counter = "LINE" + value = "MISSEDCOUNT" + maximum = 0 + } + excludes = knownMissedLinesForMethods.collect { partialSignature, maxMissedLines -> + "com.launchdarkly.sdk." + partialSignature + } + } + } +} + idea { module { downloadJavadoc = true diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java index fe4c004..17e0084 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -297,7 +297,7 @@ public static EvaluationReason error(ErrorKind errorKind) { 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 EvaluationReason(errorKind, null); + default: return new EvaluationReason(errorKind, null); // COVERAGE: compiler requires default but there are no other ErrorKind values } } diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java index 19e1882..a505eb9 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java @@ -8,6 +8,9 @@ import java.io.IOException; +import static com.launchdarkly.sdk.Helpers.readNonNullableString; +import static com.launchdarkly.sdk.Helpers.readNullableString; + final class EvaluationReasonTypeAdapter extends TypeAdapter { @Override public EvaluationReason read(JsonReader reader) throws IOException { @@ -24,26 +27,31 @@ static EvaluationReason parse(JsonReader reader) throws IOException { reader.beginObject(); while (reader.peek() != JsonToken.END_OBJECT) { String key = reader.nextName(); - switch (key) { + switch (key) { // COVERAGE: may have spurious "branches missed" warning, see https://stackoverflow.com/questions/28013717/eclemma-branch-coverage-for-switch-7-of-19-missed case "kind": - kind = Enum.valueOf(EvaluationReason.Kind.class, reader.nextString()); + kind = Enum.valueOf(EvaluationReason.Kind.class, readNonNullableString(reader)); break; case "ruleIndex": ruleIndex = reader.nextInt(); break; case "ruleId": - ruleId = reader.nextString(); + ruleId = readNullableString(reader); break; case "prerequisiteKey": prereqKey = reader.nextString(); break; case "errorKind": - errorKind = Enum.valueOf(EvaluationReason.ErrorKind.class, reader.nextString()); + errorKind = Enum.valueOf(EvaluationReason.ErrorKind.class, readNonNullableString(reader)); break; + default: + reader.skipValue(); // ignore any unexpected property } } reader.endObject(); + if (kind == null) { + throw new JsonParseException("EvaluationReason missing required property \"kind\""); + } switch (kind) { case OFF: return EvaluationReason.off(); @@ -57,8 +65,10 @@ static EvaluationReason parse(JsonReader reader) throws IOException { return EvaluationReason.prerequisiteFailed(prereqKey); case ERROR: return EvaluationReason.error(errorKind); + default: + // COVERAGE: compiler requires default but there are no other values + return null; } - throw new JsonParseException("EvaluationReason missing required property \"kind\""); } @Override diff --git a/src/main/java/com/launchdarkly/sdk/Helpers.java b/src/main/java/com/launchdarkly/sdk/Helpers.java index 6a42c32..e0c85b4 100644 --- a/src/main/java/com/launchdarkly/sdk/Helpers.java +++ b/src/main/java/com/launchdarkly/sdk/Helpers.java @@ -1,5 +1,9 @@ package com.launchdarkly.sdk; +import com.google.gson.JsonParseException; +import com.google.gson.stream.JsonReader; + +import java.io.IOException; import java.util.Iterator; /** @@ -28,4 +32,26 @@ public U next() { } }; } + + // Necessary because Gson's nextString() doesn't allow nulls and *does* allow non-string values + static String readNullableString(JsonReader reader) throws IOException { + switch (reader.peek()) { + case STRING: + return reader.nextString(); + case NULL: + reader.nextNull(); + return null; + default: + throw new JsonParseException("expected string value or null"); + } + } + + static String readNonNullableString(JsonReader reader) throws IOException { + switch (reader.peek()) { + case STRING: + return reader.nextString(); + default: + throw new JsonParseException("expected string value"); + } + } } diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index 910fe95..11d7942 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -7,6 +7,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -666,7 +667,7 @@ public Builder privateCustom(String k, LDValue v) { void addPrivate(UserAttribute attribute) { if (privateAttributes == null) { - privateAttributes = new HashSet<>(); + privateAttributes = new LinkedHashSet<>(); // LinkedHashSet preserves insertion order, for test determinacy } privateAttributes.add(attribute); } diff --git a/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java index 1fd223c..9528781 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java @@ -7,6 +7,8 @@ import java.io.IOException; +import static com.launchdarkly.sdk.Helpers.readNullableString; + final class LDUserTypeAdapter extends TypeAdapter{ static final LDUserTypeAdapter INSTANCE = new LDUserTypeAdapter(); @@ -16,36 +18,40 @@ public LDUser read(JsonReader reader) throws IOException { reader.beginObject(); while (reader.peek() != JsonToken.END_OBJECT) { String key = reader.nextName(); - switch (key) { + switch (key) { // COVERAGE: may have spurious "branches missed" warning, see https://stackoverflow.com/questions/28013717/eclemma-branch-coverage-for-switch-7-of-19-missed case "key": - builder.key(reader.nextString()); + builder.key(readNullableString(reader)); break; case "secondary": - builder.secondary(reader.nextString()); + builder.secondary(readNullableString(reader)); break; case "ip": - builder.ip(reader.nextString()); + builder.ip(readNullableString(reader)); break; case "email": - builder.email(reader.nextString()); + builder.email(readNullableString(reader)); break; case "name": - builder.name(reader.nextString()); + builder.name(readNullableString(reader)); break; case "avatar": - builder.avatar(reader.nextString()); + builder.avatar(readNullableString(reader)); break; case "firstName": - builder.firstName(reader.nextString()); + builder.firstName(readNullableString(reader)); break; case "lastName": - builder.lastName(reader.nextString()); + builder.lastName(readNullableString(reader)); break; case "country": - builder.country(reader.nextString()); + builder.country(readNullableString(reader)); break; case "anonymous": - builder.anonymous(reader.nextBoolean()); + if (reader.peek() == JsonToken.NULL) { + reader.nextNull(); + } else { + builder.anonymous(reader.nextBoolean()); + } break; case "custom": if (reader.peek() == JsonToken.NULL) { @@ -73,7 +79,8 @@ public LDUser read(JsonReader reader) throws IOException { } break; default: - LDValueTypeAdapter.INSTANCE.read(reader); + // ignore unknown top-level keys + reader.skipValue(); } } reader.endObject(); @@ -94,7 +101,7 @@ public void write(JsonWriter writer, LDUser user) throws IOException { writer.beginObject(); for (UserAttribute attr: UserAttribute.BUILTINS.values()) { LDValue value = user.getAttribute(attr); - if (value != null && !value.isNull()) { + if (!value.isNull()) { writer.name(attr.getName()); LDValueTypeAdapter.INSTANCE.write(writer, value); } diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index 5a4adad..b358025 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -328,6 +328,10 @@ public Iterable values() { * LDValue anArrayOfInts = LDValue.Convert.Integer.arrayOf(1, 2, 3); * for (int i: anArrayOfInts.valuesAs(LDValue.Convert.Integer)) { println(i); } * + *

+ * For boolean and numeric types, even though the corresponding Java type is a nullable class like + * {@code Boolean} or {@code Integer}, {@code valuesAs} will never return a null element; instead, + * it will use the appropriate default value for the primitive type (false or zero). * * @param the desired type * @param converter the {@link Converter} for the specified type @@ -411,9 +415,9 @@ public boolean equals(Object o) { LDValue other = (LDValue)o; if (getType() == other.getType()) { switch (getType()) { - case NULL: return other.isNull(); - case BOOLEAN: return booleanValue() == other.booleanValue(); + case NULL: return other.isNull(); // COVERAGE: won't hit this case because ofNull() is a singleton, so (o == this) will be true case NUMBER: return doubleValue() == other.doubleValue(); + case BOOLEAN: return false; // boolean true and false are singletons, so if o != this, they're unequal case STRING: return stringValue().equals(other.stringValue()); case ARRAY: if (size() != other.size()) { @@ -435,6 +439,8 @@ public boolean equals(Object o) { } } return true; + default: + break; } } } @@ -444,7 +450,6 @@ public boolean equals(Object o) { @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(); @@ -460,7 +465,8 @@ public int hashCode() { oh = (oh * 31 + name.hashCode()) * 31 + get(name).hashCode(); } return oh; - default: return 0; + default: + return 0; } } diff --git a/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java index acdfa7c..bd5f419 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java @@ -35,6 +35,7 @@ public LDValue read(JsonReader reader) throws IOException { case BOOLEAN: return LDValue.of(reader.nextBoolean()); case NULL: + // COVERAGE: this branch won't be reached because Gson does not call the TypeAdapter when there's a null. reader.nextNull(); return LDValue.ofNull(); case NUMBER: @@ -42,6 +43,7 @@ public LDValue read(JsonReader reader) throws IOException { case STRING: return LDValue.of(reader.nextString()); default: + // COVERAGE: this branch won't be reached because Gson does not call the TypeAdapter if the next token isn't well-formed JSON. return null; } } diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index ae35f0e..ee8b8de 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -113,6 +113,10 @@ static T deserializeInternalGson(String json, Type objectType) throws Serial * @return classes we should have a custom deserializer for */ static Iterable> getDeserializableClasses() { + // COVERAGE: This method should be excluded from code coverage analysis, because we can't test the + // reflective SDK extension logic inside this repo. SdkSerializationExtensions is not defined in this + // repo by necessity, and if we defined it in the test code then we would not be able to test the + // default case where it *doesn't* exist. This functionality is tested in the Java SDK. synchronized (knownDeserializableClasses) { if (knownDeserializableClasses.isEmpty()) { knownDeserializableClasses.add(EvaluationReason.class); diff --git a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java index ed19665..2c26680 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java @@ -70,12 +70,9 @@ private static class LDJacksonSerializer extends JsonSerializer values = new ArrayList<>(); + for (LDValue v: LDValue.buildArray().add(a).add(b).build().values()) { + values.add(v); + } + List expected = new ArrayList<>(); + addAll(expected, a, b); + assertEquals(expected, values); + } + + @Test + public void arrayBuilderOverloadsForPrimitiveTypes() { + LDValue a = LDValue.buildArray() + .add(true) + .add(1) + .add(2L) + .add(3.5f) + .add(4.5d) + .add("x") + .build(); + LDValue expected = LDValue.buildArray() + .add(LDValue.of(true)) + .add(LDValue.of(1)) + .add(LDValue.of(2L)) + .add(LDValue.of(3.5f)) + .add(LDValue.of(4.5d)) + .add(LDValue.of("x")) + .build(); + assertEquals(expected, a); + } + + @Test + public void arrayBuilderCanAddValuesAfterBuilding() { + ArrayBuilder builder = LDValue.buildArray(); + builder.add("a"); + LDValue firstArray = builder.build(); + assertEquals(1, firstArray.size()); + builder.add("b"); + LDValue secondArray = builder.build(); + assertEquals(2, secondArray.size()); + assertEquals(1, firstArray.size()); + } + + @Test + public void primitiveValuesBehaveLikeEmptyArray() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + LDValue.of(true), + LDValue.of(1), + LDValue.of(1L), + LDValue.of(1.0f), + LDValue.of(1.0d), + LDValue.of("x") + }; + 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)); + assertThat(value.values(), Matchers.emptyIterable()); + } + } + + @Test + public void equalValuesAreEqual() + { + List> testValues = asList( + asList(LDValue.buildArray().build(), LDValue.buildArray().build()), + asList(LDValue.buildArray().add("a").build(), LDValue.buildArray().add("a").build()), + asList(LDValue.buildArray().add("a").add("b").build(), + LDValue.buildArray().add("a").add("b").build()), + asList(LDValue.buildArray().add("a").add("c").build(), + LDValue.buildArray().add("a").add("c").build()), + asList(LDValue.buildArray().add("a").add(LDValue.buildArray().add("b").add("c").build()).build(), + LDValue.buildArray().add("a").add(LDValue.buildArray().add("b").add("c").build()).build()), + asList(LDValue.buildArray().add("a").add(LDValue.buildArray().add("b").add("d").build()).build(), + LDValue.buildArray().add("a").add(LDValue.buildArray().add("b").add("d").build()).build()) + ); + TestHelpers.doEqualityTests(testValues); + } + + @Test + public void testTypeConversions() { + testTypeConversion(LDValue.Convert.Boolean, new Boolean[] { true, false }, false, LDValue.of(true), LDValue.of(false)); + testTypeConversion(LDValue.Convert.Integer, new Integer[] { 1, 2 }, 0, LDValue.of(1), LDValue.of(2)); + testTypeConversion(LDValue.Convert.Long, new Long[] { 1L, 2L }, 0L, LDValue.of(1L), LDValue.of(2L)); + testTypeConversion(LDValue.Convert.Float, new Float[] { 1.5f, 2.5f }, 0f, LDValue.of(1.5f), LDValue.of(2.5f)); + testTypeConversion(LDValue.Convert.Double, new Double[] { 1.5d, 2.5d }, 0d, LDValue.of(1.5d), LDValue.of(2.5d)); + testTypeConversion(LDValue.Convert.String, new String[] { "a", "b" }, null, LDValue.of("a"), LDValue.of("b")); + } + + private void testTypeConversion(LDValue.Converter converter, T[] values, T valueForNull, LDValue... ldValues) { + ArrayBuilder ab = LDValue.buildArray(); + for (LDValue v: ldValues) { + ab.add(v); + } + ab.add(LDValue.ofNull()); // all the types we're testing are by definition nullable + LDValue arrayValue = ab.build(); + + T[] allValues = Arrays.copyOf(values, values.length + 1); + allValues[values.length] = null; + assertEquals(arrayValue, converter.arrayOf(allValues)); + + List listWithActualNull = new ArrayList<>(); + List listWithDefaultValueForNull = new ArrayList<>(); + for (T v: values) { + listWithActualNull.add(v); + listWithDefaultValueForNull.add(v); + } + listWithActualNull.add(null); + listWithDefaultValueForNull.add(valueForNull); // see doc comment for LDValue.valuesAs() + assertEquals(arrayValue, converter.arrayFrom(listWithActualNull)); + assertEquals(listWithDefaultValueForNull, listFromIterable(arrayValue.valuesAs(converter))); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/LDValueObjectTest.java b/src/test/java/com/launchdarkly/sdk/LDValueObjectTest.java new file mode 100644 index 0000000..3fe8b77 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/LDValueObjectTest.java @@ -0,0 +1,148 @@ +package com.launchdarkly.sdk; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static java.util.Arrays.asList; +import static java.util.Collections.addAll; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyIterable; +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") +public class LDValueObjectTest { + private static final LDValue anObjectValue = LDValue.buildObject().put("1", LDValue.of("x")).build(); + + @Test + public void canGetSizeOfObject() { + assertEquals(1, anObjectValue.size()); + } + + @Test + public void objectCanGetValueByName() { + assertEquals(LDValueType.OBJECT, anObjectValue.getType()); + assertEquals(LDValue.of("x"), anObjectValue.get("1")); + assertEquals(LDValue.ofNull(), anObjectValue.get(null)); + assertEquals(LDValue.ofNull(), anObjectValue.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); + List expected = new ArrayList<>(); + addAll(expected, "1", "2"); + assertEquals(expected, 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); + List expected = new ArrayList<>(); + addAll(expected, "x", "y"); + assertEquals(expected, values); + } + + @Test + public void objectBuilderOverloadsForPrimitiveTypes() { + LDValue a = LDValue.buildObject() + .put("a", true) + .put("b", 1) + .put("c", 2L) + .put("d", 3.5f) + .put("e", 4.5d) + .put("f", "x") + .build(); + LDValue expected = LDValue.buildObject() + .put("a", LDValue.of(true)) + .put("b", LDValue.of(1)) + .put("c", LDValue.of(2L)) + .put("d", LDValue.of(3.5f)) + .put("e", LDValue.of(4.5d)) + .put("f", LDValue.of("x")) + .build(); + assertEquals(expected, a); + } + + @Test + public void objectBuilderCanAddValuesAfterBuilding() { + ObjectBuilder builder = LDValue.buildObject(); + builder.put("a", 1); + LDValue firstObject = builder.build(); + assertEquals(1, firstObject.size()); + builder.put("b", 2); + LDValue secondObject = builder.build(); + assertEquals(2, secondObject.size()); + assertEquals(1, firstObject.size()); + } + + @Test + public void primitiveValuesBehaveLikeEmptyObject() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + LDValue.ofNull(), + LDValue.of(true), + LDValue.of(1), + LDValue.of(1L), + LDValue.of(1.0f), + LDValue.of(1.0d), + LDValue.of("x") + }; + for (LDValue value: values) { + assertEquals(value.toString(), LDValue.of(null), value.get(null)); + assertEquals(value.toString(), LDValue.of(null), value.get("1")); + assertThat(value.keys(), emptyIterable()); + } + } + + @Test + public void equalValuesAreEqual() + { + List> testValues = asList( + asList(LDValue.buildObject().build(), LDValue.buildObject().build()), + asList(LDValue.buildObject().put("a", LDValue.of(1)).build(), + LDValue.buildObject().put("a", LDValue.of(1)).build()), + asList(LDValue.buildObject().put("a", LDValue.of(2)).build(), + LDValue.buildObject().put("a", LDValue.of(2)).build()), + asList(LDValue.buildObject().put("a", LDValue.of(1)).put("b", LDValue.of(2)).build(), + LDValue.buildObject().put("b", LDValue.of(2)).put("a", LDValue.of(1)).build()) + ); + TestHelpers.doEqualityTests(testValues); + } + + @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) { + ObjectBuilder ob = LDValue.buildObject(); + int i = 0; + for (LDValue v: ldValues) { + ob.put(String.valueOf(++i), v); + } + LDValue objectValue = ob.build(); + Map map = new HashMap<>(); + i = 0; + for (T v: values) { + map.put(String.valueOf(++i), v); + } + assertEquals(objectValue, converter.objectFrom(map)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/LDValueTest.java b/src/test/java/com/launchdarkly/sdk/LDValueTest.java index f386eab..ba295af 100644 --- a/src/test/java/com/launchdarkly/sdk/LDValueTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueTest.java @@ -4,22 +4,17 @@ import org.junit.Test; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import static com.launchdarkly.sdk.TestHelpers.listFromIterable; import static java.util.Arrays.asList; -import static java.util.Collections.addAll; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.instanceOf; 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("javadoc") public class LDValueTest { @@ -39,6 +34,58 @@ public class LDValueTest { 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(); + @Test + public void normalize() { + assertEquals(LDValue.ofNull(), LDValue.normalize(null)); + assertEquals(LDValue.ofNull(), LDValue.normalize(LDValue.ofNull())); + assertEquals(LDValue.of(true), LDValue.normalize(LDValue.of(true))); + } + + @Test + public void isNull() { + assertTrue(LDValue.ofNull().isNull()); + LDValue[] nonNulls = new LDValue[] { aStringValue, anIntValue, aLongValue, aFloatValue, + aDoubleValue, anArrayValue, anObjectValue }; + for (LDValue value: nonNulls) { + assertFalse(value.toString(), value.isNull()); + } + } + + @Test + public void isNumber() { + LDValue[] nonNumerics = new LDValue[] { LDValue.ofNull(), aStringValue, anArrayValue, anObjectValue }; + LDValue[] numerics = new LDValue[] { anIntValue, aLongValue, aFloatValue, aDoubleValue }; + for (LDValue value: nonNumerics) { + assertFalse(value.toString(), value.isNumber()); + } + for (LDValue value: numerics) { + assertTrue(value.toString(), value.isNumber()); + } + } + + @Test + public void isInt() { + LDValue[] nonInts = new LDValue[] { LDValue.ofNull(), aStringValue, anArrayValue, anObjectValue, + LDValue.of(1.5f), LDValue.of(1.5d) }; + LDValue[] ints = new LDValue[] { anIntValue, aLongValue, LDValue.of(1.0f), LDValue.of(1.0d) }; + for (LDValue value: nonInts) { + assertFalse(value.toString(), value.isInt()); + } + for (LDValue value: ints) { + assertTrue(value.toString(), value.isInt()); + } + } + + @Test + public void isString() { + LDValue[] nonStrings = new LDValue[] { anIntValue, aLongValue, aFloatValue, + aDoubleValue, anArrayValue, anObjectValue }; + assertTrue(aStringValue.isString()); + for (LDValue value: nonStrings) { + assertFalse(value.toString(), value.isString()); + } + } + @Test public void canGetValueAsBoolean() { assertEquals(LDValueType.BOOLEAN, aTrueBoolValue.getType()); @@ -58,8 +105,9 @@ public void nonBooleanValueAsBooleanIsFalse() { anObjectValue, }; for (LDValue value: values) { - assertNotEquals(value.toString(), LDValueType.BOOLEAN, value.getType()); - assertFalse(value.toString(), value.booleanValue()); + String desc = value.toString(); + assertNotEquals(desc, LDValueType.BOOLEAN, value.getType()); + assertFalse(desc, value.booleanValue()); } } @@ -82,8 +130,9 @@ public void nonStringValueAsStringIsNull() { anObjectValue }; for (LDValue value: values) { - assertNotEquals(value.toString(), LDValueType.STRING, value.getType()); - assertNull(value.toString(), value.stringValue()); + String desc = value.toString(); + assertNotEquals(desc, LDValueType.STRING, value.getType()); + assertNull(desc, value.stringValue()); } } @@ -105,9 +154,10 @@ public void canGetIntegerValueOfAnyNumericType() { 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()); + String desc = value.toString(); + assertEquals(desc, LDValueType.NUMBER, value.getType()); + assertEquals(desc, 3, value.intValue()); + assertEquals(desc, 3L, value.longValue()); } } @@ -120,8 +170,9 @@ public void canGetFloatValueOfAnyNumericType() { LDValue.of(3.0d), }; for (LDValue value: values) { - assertEquals(value.toString(), LDValueType.NUMBER, value.getType()); - assertEquals(value.toString(), 3.0f, value.floatValue(), 0); + String desc = value.toString(); + assertEquals(desc, LDValueType.NUMBER, value.getType()); + assertEquals(desc, 3.0f, value.floatValue(), 0); } } @@ -134,8 +185,9 @@ public void canGetDoubleValueOfAnyNumericType() { LDValue.of(3.0d), }; for (LDValue value: values) { - assertEquals(value.toString(), LDValueType.NUMBER, value.getType()); - assertEquals(value.toString(), 3.0d, value.doubleValue(), 0); + String desc = value.toString(); + assertEquals(desc, LDValueType.NUMBER, value.getType()); + assertEquals(desc, 3.0d, value.doubleValue(), 0); } } @@ -150,146 +202,17 @@ public void nonNumericValueAsNumberIsZero() { anObjectValue }; 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); + String desc = value.toString(); + assertNotEquals(desc, LDValueType.NUMBER, value.getType()); + assertEquals(desc, 0, value.intValue()); + assertEquals(desc, 0, value.longValue()); + assertEquals(desc, 0f, value.floatValue(), 0); + assertEquals(desc, 0d, value.doubleValue(), 0); } } @Test - public void canGetSizeOfArray() { - assertEquals(1, anArrayValue.size()); - } - - @Test - public void arrayCanGetItemByIndex() { - assertEquals(LDValueType.ARRAY, anArrayValue.getType()); - assertEquals(LDValue.of(3), anArrayValue.get(0)); - assertEquals(LDValue.ofNull(), anArrayValue.get(-1)); - assertEquals(LDValue.ofNull(), anArrayValue.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); - } - List expected = new ArrayList<>(); - addAll(expected, a, b); - assertEquals(expected, values); - } - - @Test - public void arrayBuilderCanAddValuesAfterBuilding() { - ArrayBuilder builder = LDValue.buildArray(); - builder.add("a"); - LDValue firstArray = builder.build(); - assertEquals(1, firstArray.size()); - builder.add("b"); - LDValue secondArray = builder.build(); - assertEquals(2, secondArray.size()); - assertEquals(1, firstArray.size()); - } - - @Test - public void nonArrayValuesBehaveLikeEmptyArray() { - LDValue[] values = new LDValue[] { - LDValue.ofNull(), - aTrueBoolValue, - 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()); - } - - @Test - public void objectCanGetValueByName() { - assertEquals(LDValueType.OBJECT, anObjectValue.getType()); - assertEquals(LDValue.of("x"), anObjectValue.get("1")); - assertEquals(LDValue.ofNull(), anObjectValue.get(null)); - assertEquals(LDValue.ofNull(), anObjectValue.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); - List expected = new ArrayList<>(); - addAll(expected, "1", "2"); - assertEquals(expected, 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); - List expected = new ArrayList<>(); - addAll(expected, "x", "y"); - assertEquals(expected, values); - } - - @Test - public void objectBuilderCanAddValuesAfterBuilding() { - ObjectBuilder builder = LDValue.buildObject(); - builder.put("a", 1); - LDValue firstObject = builder.build(); - assertEquals(1, firstObject.size()); - builder.put("b", 2); - LDValue secondObject = builder.build(); - assertEquals(2, secondObject.size()); - assertEquals(1, firstObject.size()); - } - - @Test - public void nonObjectValuesBehaveLikeEmptyObject() { - LDValue[] values = new LDValue[] { - LDValue.ofNull(), - aTrueBoolValue, - 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 equalValuesAreEqual() - { + public void equalValuesAreEqual() { List> testValues = asList( asList(LDValue.ofNull(), LDValue.ofNull()), asList(LDValue.of(true), LDValue.of(true)), @@ -298,32 +221,19 @@ public void equalValuesAreEqual() asList(LDValue.of(2), LDValue.of(2)), asList(LDValue.of(3), LDValue.of(3.0f)), asList(LDValue.of("a"), LDValue.of("a")), - asList(LDValue.of("b"), LDValue.of("b")), - - // arrays use deep equality - asList(LDValue.buildArray().build(), LDValue.buildArray().build()), - asList(LDValue.buildArray().add("a").build(), LDValue.buildArray().add("a").build()), - asList(LDValue.buildArray().add("a").add("b").build(), - LDValue.buildArray().add("a").add("b").build()), - asList(LDValue.buildArray().add("a").add("c").build(), - LDValue.buildArray().add("a").add("c").build()), - asList(LDValue.buildArray().add("a").add(LDValue.buildArray().add("b").add("c").build()).build(), - LDValue.buildArray().add("a").add(LDValue.buildArray().add("b").add("c").build()).build()), - asList(LDValue.buildArray().add("a").add(LDValue.buildArray().add("b").add("d").build()).build(), - LDValue.buildArray().add("a").add(LDValue.buildArray().add("b").add("d").build()).build()), - - // objects use deep equality - asList(LDValue.buildObject().build(), LDValue.buildObject().build()), - asList(LDValue.buildObject().put("a", LDValue.of(1)).build(), - LDValue.buildObject().put("a", LDValue.of(1)).build()), - asList(LDValue.buildObject().put("a", LDValue.of(2)).build(), - LDValue.buildObject().put("a", LDValue.of(2)).build()), - asList(LDValue.buildObject().put("a", LDValue.of(1)).put("b", LDValue.of(2)).build(), - LDValue.buildObject().put("b", LDValue.of(2)).put("a", LDValue.of(1)).build()) + asList(LDValue.of("b"), LDValue.of("b")) ); TestHelpers.doEqualityTests(testValues); } + @Test + public void commonValuesAreInterned() { + assertSame(LDValue.of(true), LDValue.of(true)); + assertSame(LDValue.of(false), LDValue.of(false)); + assertSame(LDValue.of(0), LDValue.of(0)); + assertSame(LDValue.of(""), LDValue.of("")); + } + @Test public void canUseLongTypeForNumberGreaterThanMaxInt() { long n = (long)Integer.MAX_VALUE + 1; @@ -339,44 +249,6 @@ public void canUseDoubleTypeForNumberGreaterThanMaxFloat() { assertEquals(n, LDValue.Convert.Double.toType(LDValue.of(n)).doubleValue(), 0); assertEquals(n, LDValue.Convert.Double.fromType(n).doubleValue(), 0); } - - @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)); - List list = new ArrayList<>(); - for (T v: values) { - list.add(v); - } - assertEquals(arrayValue, converter.arrayFrom(list)); - assertEquals(list, listFromIterable(arrayValue.valuesAs(converter))); - - ObjectBuilder ob = LDValue.buildObject(); - int i = 0; - for (LDValue v: ldValues) { - ob.put(String.valueOf(++i), v); - } - LDValue objectValue = ob.build(); - Map map = new HashMap<>(); - i = 0; - for (T v: values) { - map.put(String.valueOf(++i), v); - } - assertEquals(objectValue, converter.objectFrom(map)); - } @Test public void parseThrowsRuntimeExceptionForMalformedJson() { diff --git a/src/test/java/com/launchdarkly/sdk/TestHelpers.java b/src/test/java/com/launchdarkly/sdk/TestHelpers.java index e061219..7389cc8 100644 --- a/src/test/java/com/launchdarkly/sdk/TestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/TestHelpers.java @@ -10,6 +10,11 @@ @SuppressWarnings("javadoc") public class TestHelpers { + // Provided only because UserAttribute.BUILTINS isn't public + public static Iterable builtInAttributes() { + return UserAttribute.BUILTINS.values(); + } + public static List listFromIterable(Iterable it) { List list = new ArrayList<>(); for (T t: it) { @@ -33,16 +38,25 @@ public static void doEqualityTests(List> testValues) { // Java 7 that would be very verbose. for (int i = 0; i < testValues.size(); i++) { List equalValues = testValues.get(i); - assertEquals(equalValues.get(0), equalValues.get(0)); - assertEquals(equalValues.get(0), equalValues.get(1)); - assertEquals(equalValues.get(1), equalValues.get(0)); - assertEquals(equalValues.get(0).hashCode(), equalValues.get(1).hashCode()); + T equalValue0 = equalValues.get(0); + T equalValue1 = equalValues.get(1); + assertEquals(equalValue0, equalValue0); + assertEquals(equalValue0, equalValue1); + assertEquals(equalValue1, equalValue0); + assertEquals(equalValue0.hashCode(), equalValue1.hashCode()); + assertNotEquals(new ArbitraryClassThatDoesNotEqualOtherClasses(), equalValue0); + assertNotEquals(equalValue0, new ArbitraryClassThatDoesNotEqualOtherClasses()); + assertNotEquals(null, equalValue0); + assertNotEquals(equalValue0, null); for (int j = 0; j < testValues.size(); j++) { if (j != i) { - assertNotEquals(testValues.get(j).get(0), equalValues.get(0)); - assertNotEquals(equalValues.get(0), testValues.get(j).get(0)); + T unequalValue = testValues.get(j).get(0); + assertNotEquals(equalValue0, unequalValue); + assertNotEquals(unequalValue, equalValue0); } } } } + + private static final class ArbitraryClassThatDoesNotEqualOtherClasses {} } diff --git a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java index c90e19b..87a6363 100644 --- a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java +++ b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.List; +import static com.launchdarkly.sdk.TestHelpers.builtInAttributes; import static java.util.Arrays.asList; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -75,11 +76,16 @@ public void customAttribute() { @Test public void equalInstancesAreEqual() { List> testValues = new ArrayList<>(); - for (UserAttribute attr: UserAttribute.BUILTINS.values()) { + for (UserAttribute attr: builtInAttributes()) { testValues.add(asList(attr, UserAttribute.forName(attr.getName()))); } testValues.add(asList(UserAttribute.forName("custom1"), UserAttribute.forName("custom1"))); testValues.add(asList(UserAttribute.forName("custom2"), UserAttribute.forName("custom2"))); TestHelpers.doEqualityTests(testValues); } + + @Test + public void simpleStringRepresentation() { + assertEquals("name", UserAttribute.NAME.toString()); + } } diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java index ebd3d6c..738018d 100644 --- a/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java @@ -8,6 +8,7 @@ import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.CLIENT_NOT_READY; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; @SuppressWarnings("javadoc") @@ -22,6 +23,9 @@ public void detailJsonSerializations() throws Exception { EvaluationDetail.fromValue(LDValue.of("x"), NO_VARIATION, EvaluationReason.error(CLIENT_NOT_READY)), "{\"value\":\"x\",\"reason\":{\"kind\":\"ERROR\",\"errorKind\":\"CLIENT_NOT_READY\"}}"); + verifySerialize(EvaluationDetail.fromValue((String)null, 1, EvaluationReason.off()), + "{\"variationIndex\":1,\"reason\":{\"kind\":\"OFF\"}}"); // Gson will omit the "value: null" + // Due to how generic types work in Gson, simply calling Gson.fromJson> will *not* // use any custom deserialization for type T; it will behave as if T were LDValue. However, it should // correctly pick up the type signature if you deserialize an object that contains such a value. That diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java index aa85955..2604791 100644 --- a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java @@ -17,12 +17,20 @@ public void reasonJsonSerializations() throws Exception { verifySerializeAndDeserialize(EvaluationReason.targetMatch(), "{\"kind\":\"TARGET_MATCH\"}"); verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, "id"), "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\"}"); + verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, null), + "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1}"); verifySerializeAndDeserialize(EvaluationReason.prerequisiteFailed("key"), "{\"kind\":\"PREREQUISITE_FAILED\",\"prerequisiteKey\":\"key\"}"); verifySerializeAndDeserialize(EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND), "{\"kind\":\"ERROR\",\"errorKind\":\"FLAG_NOT_FOUND\"}"); + // unknown properties are ignored + JsonTestHelpers.verifyDeserialize(EvaluationReason.off(), "{\"kind\":\"OFF\",\"other\":true}"); + verifyDeserializeInvalidJson(EvaluationReason.class, "3"); + verifyDeserializeInvalidJson(EvaluationReason.class, "{}"); // must have "kind" + verifyDeserializeInvalidJson(EvaluationReason.class, "{\"kind\":3}"); + verifyDeserializeInvalidJson(EvaluationReason.class, "{\"kind\":\"other\"}"); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java index f7b90f5..13ec30c 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java @@ -2,9 +2,12 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; import org.junit.Test; +import static com.launchdarkly.sdk.TestHelpers.builtInAttributes; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; @@ -32,6 +35,7 @@ public void defaultJsonEncodingWithoutPrivateAttributes() throws Exception { .country("c") .anonymous(true) .custom("c1", "v1") + .custom("c2", "v2") .build(); LDValue expectedJson = LDValue.buildObject() .put("key", "userkey") @@ -44,7 +48,7 @@ public void defaultJsonEncodingWithoutPrivateAttributes() throws Exception { .put("lastName", "l") .put("country", "c") .put("anonymous", true) - .put("custom", LDValue.buildObject().put("c1", "v1").build()) + .put("custom", LDValue.buildObject().put("c1", "v1").put("c2", "v2").build()) .build(); verifySerializeAndDeserialize(user, expectedJson.toJsonString()); } @@ -54,13 +58,34 @@ public void defaultJsonEncodingWithPrivateAttributes() throws Exception { LDUser user = new LDUser.Builder("userkey") .email("e") .privateName("n") + .privateCountry("c") .build(); LDValue expectedJson = LDValue.buildObject() .put("key", "userkey") .put("email", "e") .put("name", "n") - .put("privateAttributeNames", LDValue.buildArray().add("name").build()) + .put("country", "c") + .put("privateAttributeNames", LDValue.buildArray().add("name").add("country").build()) .build(); verifySerializeAndDeserialize(user, expectedJson.toJsonString()); } + + @Test + public void explicitNullsAreIgnored() throws Exception { + LDUser user = new LDUser("userkey"); + StringBuilder sb = new StringBuilder().append("{\"key\":\"userkey\""); + for (UserAttribute a: builtInAttributes()) { + if (a != UserAttribute.KEY) { + sb.append(",\"").append(a.getName()).append("\":null"); + } + } + sb.append(",\"custom\":null,\"privateAttributeNames\":null}"); + verifyDeserialize(user, sb.toString()); + } + + @Test + public void unknownKeysAreIgnored() throws Exception { + LDUser user = new LDUser.Builder("userkey").name("x").build(); + verifyDeserialize(user, "{\"key\":\"userkey\",\"other\":true,\"name\":\"x\"}"); + } } diff --git a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java index 8e44bd7..2e40f37 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java @@ -5,6 +5,7 @@ import org.junit.Test; import static com.launchdarkly.sdk.json.JsonTestHelpers.parseElement; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; import static org.junit.Assert.assertEquals; @@ -27,6 +28,7 @@ public void jsonEncodingForNonNullValues() throws Exception { verifyValueSerialization(LDValue.of(2.5d), "2.5"); verifyValueSerialization(LDValue.buildArray().add(2).add("x").build(), "[2,\"x\"]"); verifyValueSerialization(LDValue.buildObject().put("x", 2).build(), "{\"x\":2}"); + verifyDeserializeInvalidJson(LDValue.class, "]"); } private static void verifyValueSerialization(LDValue value, String expectedJsonString) throws Exception { From 7a2aa77d037d243a40daa3fd6bdacb1ea5e6713e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 29 Apr 2020 11:40:59 -0700 Subject: [PATCH 21/91] build and test in Android! (#13) --- .circleci/config.yml | 50 +++++++++++ .gitignore | 4 +- build-android.gradle | 66 ++++++++++++++ build-shared.gradle | 33 +++++++ build.gradle | 30 +------ scripts/circleci/LICENSE | 21 +++++ scripts/circleci/circle-android | 88 +++++++++++++++++++ scripts/install-android-tools.sh | 23 +++++ scripts/run-android-tests.sh | 17 ++++ scripts/start-android-env.sh | 8 ++ scripts/started-android-env.sh | 9 ++ scripts/stop-android-env.sh | 5 ++ src/androidTest/AndroidManifest.xml | 14 +++ .../java/com/launchdarkly/sdk/BaseTest.java | 12 +++ src/main/AndroidManifest.xml | 6 ++ .../java/com/launchdarkly/sdk/BaseTest.java | 9 ++ .../sdk/EvaluationDetailTest.java | 2 +- .../sdk/EvaluationReasonTest.java | 2 +- .../java/com/launchdarkly/sdk/LDUserTest.java | 2 +- .../launchdarkly/sdk/LDValueObjectTest.java | 5 +- .../com/launchdarkly/sdk/LDValueTest.java | 2 +- .../launchdarkly/sdk/UserAttributeTest.java | 2 +- ...EvaluationDetailJsonSerializationTest.java | 3 +- ...EvaluationReasonJsonSerializationTest.java | 3 +- .../sdk/json/JsonTestHelpers.java | 3 +- .../sdk/json/LDUserJsonSerializationTest.java | 3 +- .../json/LDValueJsonSerializationTest.java | 3 +- .../sdk/json/ReflectiveFrameworksTest.java | 3 +- .../UserAttributeJsonSerializationTest.java | 3 +- 29 files changed, 387 insertions(+), 44 deletions(-) create mode 100644 build-android.gradle create mode 100644 build-shared.gradle create mode 100644 scripts/circleci/LICENSE create mode 100755 scripts/circleci/circle-android create mode 100755 scripts/install-android-tools.sh create mode 100755 scripts/run-android-tests.sh create mode 100755 scripts/start-android-env.sh create mode 100755 scripts/started-android-env.sh create mode 100755 scripts/stop-android-env.sh create mode 100644 src/androidTest/AndroidManifest.xml create mode 100644 src/androidTest/java/com/launchdarkly/sdk/BaseTest.java create mode 100644 src/main/AndroidManifest.xml create mode 100644 src/test/java/com/launchdarkly/sdk/BaseTest.java diff --git a/.circleci/config.yml b/.circleci/config.yml index 9e15d94..ba4c792 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,6 +30,8 @@ workflows: - build-linux - build-test-windows: name: Java 11 - Windows - OpenJDK + - build-test-android: + name: Android jobs: build-linux: @@ -114,3 +116,51 @@ jobs: path: .\junit - store_artifacts: path: .\junit + + build-test-android: + # This is adapted from the CI build for android-client-sdk + macos: + xcode: "10.3.0" + shell: /bin/bash --login -eo pipefail + working_directory: ~/launchdarkly/android-client-sdk-private + environment: + TERM: dumb + QEMU_AUDIO_DRV: none + _JAVA_OPTIONS: "-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -Xms2048m -Xmx4096m" + GRADLE_OPTS: "-Dorg.gradle.daemon=false" + JVM_OPTS: -Xmx3200m + ANDROID_HOME: "/usr/local/share/android-sdk" + ANDROID_SDK_HOME: "/usr/local/share/android-sdk" + ANDROID_SDK_ROOT: "/usr/local/share/android-sdk" + steps: + - checkout + - run: + name: Install Android tools + command: ./scripts/install-android-tools.sh + - run: + name: Start Android environment + command: ./scripts/start-android-env.sh + background: true + timeout: 1200 + no_output_timeout: 20m + - run: + name: Wait for Android environment + command: ./scripts/started-android-env.sh + - run: + name: Run tests + command: ./scripts/run-android-tests.sh + no_output_timeout: 20m + - run: + name: Save test results + command: | + mkdir -p ~/test-results + cp -r ./build/outputs/androidTest-results/* ~/test-results/ + when: always + - run: + name: Stop Android environment + command: ./scripts/stop-android-env.sh + when: always + - store_test_results: + path: ~/test-results + - store_artifacts: + path: ~/artifacts diff --git a/.gitignore b/.gitignore index de40ed7..b8a8f70 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ bin/ out/ classes/ -packaging-test/temp/ +# Test code that gets temporarily copied by our Android CI build +src/androidTest/java/com/launchdarkly/sdk/**/*.java +!src/androidTest/java/com/launchdarkly/sdk/BaseTest.java diff --git a/build-android.gradle b/build-android.gradle new file mode 100644 index 0000000..e7b046b --- /dev/null +++ b/build-android.gradle @@ -0,0 +1,66 @@ +apply plugin: 'com.android.library' +//apply plugin: 'com.github.dcendents.android-maven' + +buildscript { + repositories { + mavenCentral() + mavenLocal() + google() + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:3.6.0' + } +} +// This Gradle script is used only when we are running tests in an Android environment to verify +// that the project is Android-compatible. We do not publish an Android build - that is done in +// the android-client-sdk project. + +repositories { + mavenLocal() + // Before LaunchDarkly release artifacts get synced to Maven Central they are here along with snapshots: + maven { url "https://oss.sonatype.org/content/groups/public/" } + mavenCentral() + google() +} + +apply from: 'build-shared.gradle' + +android { + compileSdkVersion 26 + buildToolsVersion '28.0.3' + + defaultConfig { + minSdkVersion 16 + targetSdkVersion 26 + versionCode 1 + versionName version + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + consumerProguardFiles 'consumer-proguard-rules.pro' + + // The following argument makes the Android Test Orchestrator run its + // "pm clear" command after each test invocation. This command ensures + // that the app's state is completely cleared between tests. + testInstrumentationRunnerArguments clearPackageData: 'true' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_7 + targetCompatibility JavaVersion.VERSION_1_7 + } + + packagingOptions { + exclude 'META-INF/**' + exclude 'META-INF/**' + } + + dexOptions { + javaMaxHeapSize "4g" + } +} + +dependencies { + androidTestImplementation "junit:junit:4.12" + androidTestImplementation "org.hamcrest:hamcrest-library:1.3" + androidTestImplementation "com.android.support.test:runner:1.0.2" +} diff --git a/build-shared.gradle b/build-shared.gradle new file mode 100644 index 0000000..a6ec4eb --- /dev/null +++ b/build-shared.gradle @@ -0,0 +1,33 @@ + +// These properties are in their own file to ensure that they're kept in sync between the +// main Java build (build.gradle) and the Android CI build (build-android-ci.gradle). + +allprojects { + group = 'com.launchdarkly' + version = "${version}" + archivesBaseName = "launchdarkly-java-sdk-common" + sourceCompatibility = 1.7 + targetCompatibility = 1.7 +} + +ext { + sdkBasePackage = "com.launchdarkly.sdk" + sdkBaseName = "launchdarkly-java-sdk-common" +} + +ext.versions = [ + "gson": "2.7", + "jackson": "2.10.0" +] + +ext.libraries = [:] + +dependencies { + // Dependencies will not be exposed in the pom - see below in pom.withXml block + implementation "com.google.code.gson:gson:${versions.gson}" + implementation "com.fasterxml.jackson.core:jackson-core:${versions.jackson}" + implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" + + testImplementation "org.hamcrest:hamcrest-library:1.3" + testImplementation "junit:junit:4.12" +} diff --git a/build.gradle b/build.gradle index 65bf1c9..f4fca2a 100644 --- a/build.gradle +++ b/build.gradle @@ -31,35 +31,7 @@ configurations.all { resolutionStrategy.cacheChangingModulesFor 0, 'seconds' } -allprojects { - group = 'com.launchdarkly' - version = "${version}" - archivesBaseName = "launchdarkly-java-sdk-common" - sourceCompatibility = 1.7 - targetCompatibility = 1.7 -} - -ext { - sdkBasePackage = "com.launchdarkly.sdk" - sdkBaseName = "launchdarkly-java-sdk-common" -} - -ext.versions = [ - "gson": "2.7", - "jackson": "2.10.0" -] - -ext.libraries = [:] - -dependencies { - // Dependencies will not be exposed in the pom - see below in pom.withXml block - implementation "com.google.code.gson:gson:${versions.gson}" - implementation "com.fasterxml.jackson.core:jackson-core:${versions.jackson}" - implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" - - testImplementation "org.hamcrest:hamcrest-all:1.3" - testImplementation "junit:junit:4.12" -} +apply from: 'build-shared.gradle' checkstyle { configFile file("${project.rootDir}/checkstyle.xml") diff --git a/scripts/circleci/LICENSE b/scripts/circleci/LICENSE new file mode 100644 index 0000000..1311556 --- /dev/null +++ b/scripts/circleci/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017-2019 Circle Internet Services, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/scripts/circleci/circle-android b/scripts/circleci/circle-android new file mode 100755 index 0000000..4524b60 --- /dev/null +++ b/scripts/circleci/circle-android @@ -0,0 +1,88 @@ +#!/usr/bin/env python + +# See LICENSE file in this directory for copyright and license information +# Above LICENSE notice added 10/16/2019 by Gavin Whelan + +from sys import argv, exit, stdout +from time import sleep, time +from os import system +from subprocess import check_output, CalledProcessError +from threading import Thread, Event +from functools import partial + +class StoppableThread(Thread): + + def __init__(self): + super(StoppableThread, self).__init__() + self._stop_event = Event() + self.daemon = True + + def stopped(self): + return self._stop_event.is_set() + + def run(self): + while not self.stopped(): + stdout.write('.') + stdout.flush() + sleep(2) + + def stop(self): + self._stop_event.set() + +def shell_getprop(name): + try: + return check_output(['adb', 'shell', 'getprop', name]).strip() + except CalledProcessError as e: + return '' + +start_time = time() + +def wait_for(name, fn): + stdout.write('Waiting for %s' % name) + spinner = StoppableThread() + spinner.start() + stdout.flush() + while True: + if fn(): + spinner.stop() + time_taken = int(time() - start_time) + print('\n%s is ready after %d seconds' % (name, time_taken)) + break + sleep(1) + +def device_ready(): + return system('adb wait-for-device') == 0 + +def shell_ready(): + return system('adb shell true &> /dev/null') == 0 + +def prop_has_value(prop, value): + return shell_getprop(prop) == value + +def wait_for_sys_prop(name, prop, value): + # return shell_getprop('init.svc.bootanim') == 'stopped' + wait_for(name, partial(prop_has_value, prop, value)) + +usage = """ +%s, a collection of tools for CI with android. + +Usage: + %s wait-for-boot - wait for a device to fully boot. + (adb wait-for-device only waits for it to be ready for shell access). +""" + +if __name__ == "__main__": + + if len(argv) != 2 or argv[1] != 'wait-for-boot': + print(usage % (argv[0], argv[0])) + exit(0) + + wait_for('Device', device_ready) + wait_for('Shell', shell_ready) + wait_for_sys_prop('Boot animation complete', 'init.svc.bootanim', 'stopped') + wait_for_sys_prop('Boot animation exited', 'service.bootanim.exit', '1') + wait_for_sys_prop('System boot complete', 'sys.boot_completed', '1') + wait_for_sys_prop('GSM Ready', 'gsm.sim.state', 'READY') + #wait_for_sys_prop('init.svc.clear-bcb' ,'init.svc.clear-bcb', 'stopped') + + diff --git a/scripts/install-android-tools.sh b/scripts/install-android-tools.sh new file mode 100755 index 0000000..3df504d --- /dev/null +++ b/scripts/install-android-tools.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -e -x +set +o pipefail # necessary because of how we're using "yes |" + +echo 'export PATH="$PATH:/usr/local/share/android-sdk/tools/bin"' >> $BASH_ENV +echo 'export PATH="$PATH:/usr/local/share/android-sdk/platform-tools"' >> $BASH_ENV + +HOMEBREW_NO_AUTO_UPDATE=1 brew tap homebrew/cask +HOMEBREW_NO_AUTO_UPDATE=1 brew cask install android-sdk + +yes | sdkmanager "platform-tools" \ + "platforms;android-19" \ + "extras;intel;Hardware_Accelerated_Execution_Manager" \ + "build-tools;26.0.2" \ + "system-images;android-19;default;x86" \ + "emulator" | grep -v = || true + +yes | sdkmanager --licenses + +echo no | avdmanager create avd -n ci-android-avd -f -k "system-images;android-19;default;x86" + +./gradlew -b build-android.gradle androidDependencies diff --git a/scripts/run-android-tests.sh b/scripts/run-android-tests.sh new file mode 100755 index 0000000..9592d84 --- /dev/null +++ b/scripts/run-android-tests.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# What we want to do here is somewhat unusual: we want Android to run all of our tests from +# src/test/java, but run them in the Android emulator (to prove that we're only using Java APIs +# that our minimum Android API version supports). Normally, only tests in src/androidTest/java +# would be run that way. Also, Android needs a different JUnit test runner annotation on all of +# the test classes. So we can't just run the test code as-is. +# +# This script copies all the code from src/test/java into src/androidTest/java, except for the +# base class BaseTest.java, which is already defined in src/androidTest/java to provide the +# necessary test runner annotation. Then it runs the tests in the already-started emulator. + +set -x -e -o pipefail + +rsync -r ./src/test/java/ ./src/androidTest/java/ --exclude='BaseTest.java' + +./gradlew -b build-android.gradle :connectedAndroidTest --console=plain -PdisablePreDex diff --git a/scripts/start-android-env.sh b/scripts/start-android-env.sh new file mode 100755 index 0000000..ced38d7 --- /dev/null +++ b/scripts/start-android-env.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -x -e -o pipefail + +unset ANDROID_NDK_HOME + +$ANDROID_HOME/emulator/emulator -avd ci-android-avd \ + -netdelay none -netspeed full -no-audio -no-window -no-snapshot -no-boot-anim diff --git a/scripts/started-android-env.sh b/scripts/started-android-env.sh new file mode 100755 index 0000000..9b009e7 --- /dev/null +++ b/scripts/started-android-env.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -x -e + +$(dirname $0)/circleci/circle-android wait-for-boot + +while ! adb shell getprop ro.build.version.sdk; do + sleep 1 +done diff --git a/scripts/stop-android-env.sh b/scripts/stop-android-env.sh new file mode 100755 index 0000000..31d085d --- /dev/null +++ b/scripts/stop-android-env.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -x -e -o pipefail + +adb emu kill || true diff --git a/src/androidTest/AndroidManifest.xml b/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..1ef6096 --- /dev/null +++ b/src/androidTest/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/androidTest/java/com/launchdarkly/sdk/BaseTest.java b/src/androidTest/java/com/launchdarkly/sdk/BaseTest.java new file mode 100644 index 0000000..7334fae --- /dev/null +++ b/src/androidTest/java/com/launchdarkly/sdk/BaseTest.java @@ -0,0 +1,12 @@ +package com.launchdarkly.sdk; + +import android.support.test.runner.AndroidJUnit4; +import org.junit.runner.RunWith; + +/** + * When running our unit tests in Android, we substitute this version of BaseTest which provides + * the correct test runner. + */ +@RunWith(AndroidJUnit4.class) +public abstract class BaseTest { +} diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml new file mode 100644 index 0000000..03982d1 --- /dev/null +++ b/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/test/java/com/launchdarkly/sdk/BaseTest.java b/src/test/java/com/launchdarkly/sdk/BaseTest.java new file mode 100644 index 0000000..efd7d06 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/BaseTest.java @@ -0,0 +1,9 @@ +package com.launchdarkly.sdk; + +/** + * The only purpose of this class is to support the somewhat roundabout mechanism we use in CI to run + * all of our unit tests in an Android environment too. All unit tests in this project should have this + * as a base class. + */ +public abstract class BaseTest { +} diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java index 2c96daa..c5f409c 100644 --- a/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java +++ b/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java @@ -13,7 +13,7 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class EvaluationDetailTest { +public class EvaluationDetailTest extends BaseTest { @Test public void getValue() { assertEquals("x", EvaluationDetail.fromValue("x", 0, EvaluationReason.off()).getValue()); diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java index 50db627..54625f8 100644 --- a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java @@ -19,7 +19,7 @@ import static org.junit.Assert.assertSame; @SuppressWarnings("javadoc") -public class EvaluationReasonTest { +public class EvaluationReasonTest extends BaseTest { @Test public void basicProperties() { assertEquals(OFF, EvaluationReason.off().getKind()); diff --git a/src/test/java/com/launchdarkly/sdk/LDUserTest.java b/src/test/java/com/launchdarkly/sdk/LDUserTest.java index 177c487..8b741fa 100644 --- a/src/test/java/com/launchdarkly/sdk/LDUserTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDUserTest.java @@ -23,7 +23,7 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class LDUserTest { +public class LDUserTest extends BaseTest { private static enum OptionalStringAttributes { secondary( new Function() { public String apply(LDUser u) { return u.getSecondary(); } }, diff --git a/src/test/java/com/launchdarkly/sdk/LDValueObjectTest.java b/src/test/java/com/launchdarkly/sdk/LDValueObjectTest.java index 3fe8b77..d338fff 100644 --- a/src/test/java/com/launchdarkly/sdk/LDValueObjectTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueObjectTest.java @@ -3,6 +3,7 @@ import org.junit.Test; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -36,7 +37,7 @@ public void objectKeysCanBeEnumerated() { for (String key: LDValue.buildObject().put("1", LDValue.of("x")).put("2", LDValue.of("y")).build().keys()) { keys.add(key); } - keys.sort(null); + Collections.sort(keys); List expected = new ArrayList<>(); addAll(expected, "1", "2"); assertEquals(expected, keys); @@ -48,7 +49,7 @@ public void objectValuesCanBeEnumerated() { for (LDValue value: LDValue.buildObject().put("1", LDValue.of("x")).put("2", LDValue.of("y")).build().values()) { values.add(value.stringValue()); } - values.sort(null); + Collections.sort(values); List expected = new ArrayList<>(); addAll(expected, "x", "y"); assertEquals(expected, values); diff --git a/src/test/java/com/launchdarkly/sdk/LDValueTest.java b/src/test/java/com/launchdarkly/sdk/LDValueTest.java index ba295af..af49bd9 100644 --- a/src/test/java/com/launchdarkly/sdk/LDValueTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueTest.java @@ -17,7 +17,7 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class LDValueTest { +public class LDValueTest extends BaseTest { private static final int someInt = 3; private static final long someLong = 3; private static final float someFloat = 3.25f; diff --git a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java index 87a6363..9de50b0 100644 --- a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java +++ b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java @@ -12,7 +12,7 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class UserAttributeTest { +public class UserAttributeTest extends BaseTest { @Test public void keyAttribute() { assertEquals("key", UserAttribute.KEY.getName()); diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java index 738018d..93634c7 100644 --- a/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.json; +import com.launchdarkly.sdk.BaseTest; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; @@ -12,7 +13,7 @@ import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; @SuppressWarnings("javadoc") -public class EvaluationDetailJsonSerializationTest { +public class EvaluationDetailJsonSerializationTest extends BaseTest { @Test public void detailJsonSerializations() throws Exception { verifySerializeAndDeserialize(EvaluationDetail.fromValue(LDValue.of("x"), 1, EvaluationReason.off()), diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java index 2604791..1ae82e0 100644 --- a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.json; +import com.launchdarkly.sdk.BaseTest; import com.launchdarkly.sdk.EvaluationReason; import org.junit.Test; @@ -9,7 +10,7 @@ import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; @SuppressWarnings("javadoc") -public class EvaluationReasonJsonSerializationTest { +public class EvaluationReasonJsonSerializationTest extends BaseTest { @Test public void reasonJsonSerializations() throws Exception { verifySerializeAndDeserialize(EvaluationReason.off(), "{\"kind\":\"OFF\"}"); diff --git a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java index b7b6fce..96fa087 100644 --- a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java @@ -6,13 +6,14 @@ import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import com.google.gson.JsonParseException; +import com.launchdarkly.sdk.BaseTest; import com.launchdarkly.sdk.LDValue; import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; @SuppressWarnings("javadoc") -public abstract class JsonTestHelpers { +public abstract class JsonTestHelpers extends BaseTest { // Note that when we verify the behavior of Gson with LDGson in this project's unit tests, that // is not an adequate test for whether the adapters will work in the Java SDK where there is the // additional issue of Gson types being shaded. The Java SDK project must do its own basic tests diff --git a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java index 13ec30c..18c7334 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.json; +import com.launchdarkly.sdk.BaseTest; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; @@ -12,7 +13,7 @@ import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; @SuppressWarnings("javadoc") -public class LDUserJsonSerializationTest { +public class LDUserJsonSerializationTest extends BaseTest { @Test public void minimalJsonEncoding() throws Exception { LDUser user = new LDUser("userkey"); diff --git a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java index 2e40f37..55e5511 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.json; +import com.launchdarkly.sdk.BaseTest; import com.launchdarkly.sdk.LDValue; import org.junit.Test; @@ -11,7 +12,7 @@ import static org.junit.Assert.assertEquals; @SuppressWarnings("javadoc") -public class LDValueJsonSerializationTest { +public class LDValueJsonSerializationTest extends BaseTest { @Test public void jsonEncodingForNull() throws Exception { verifySerialize(LDValue.ofNull(), "null"); diff --git a/src/test/java/com/launchdarkly/sdk/json/ReflectiveFrameworksTest.java b/src/test/java/com/launchdarkly/sdk/json/ReflectiveFrameworksTest.java index df3cc0d..e147a5b 100644 --- a/src/test/java/com/launchdarkly/sdk/json/ReflectiveFrameworksTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/ReflectiveFrameworksTest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import com.launchdarkly.sdk.BaseTest; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; @@ -17,7 +18,7 @@ import static org.junit.Assert.assertEquals; @SuppressWarnings("javadoc") -public class ReflectiveFrameworksTest { +public class ReflectiveFrameworksTest extends BaseTest { // Test classes like LDValueJsonSerializationTest already cover using all available JSON // frameworks to serialize and deserialize instances of our classes. This one tests the // ability of Gson and Jackson, when properly configured, to get the right serialization diff --git a/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java index 70a8eab..6ac6443 100644 --- a/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.json; +import com.launchdarkly.sdk.BaseTest; import com.launchdarkly.sdk.UserAttribute; import org.junit.Test; @@ -8,7 +9,7 @@ import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; @SuppressWarnings("javadoc") -public class UserAttributeJsonSerializationTest { +public class UserAttributeJsonSerializationTest extends BaseTest { @Test public void userAttributeJsonSerializations() throws Exception { verifySerializeAndDeserialize(UserAttribute.NAME, "\"name\""); From d5c3b0dcc0013ef63fc2b8819684283954b7a78b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 14:55:39 -0700 Subject: [PATCH 22/91] ensure real nulls can't be stored in an LDValue --- .../java/com/launchdarkly/sdk/ArrayBuilder.java | 2 +- .../java/com/launchdarkly/sdk/ObjectBuilder.java | 2 +- .../com/launchdarkly/sdk/LDValueArrayTest.java | 14 ++++++++++++++ .../com/launchdarkly/sdk/LDValueObjectTest.java | 14 ++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java b/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java index 0b763c5..f810899 100644 --- a/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java @@ -22,7 +22,7 @@ public ArrayBuilder add(LDValue value) { builder = new ArrayList<>(builder); copyOnWrite = false; } - builder.add(value); + builder.add(value == null ? LDValue.ofNull() : value); return this; } diff --git a/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java b/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java index 48c19fb..1697bae 100644 --- a/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java @@ -27,7 +27,7 @@ public ObjectBuilder put(String key, LDValue value) { builder = new HashMap<>(builder); copyOnWrite = false; } - builder.put(key, value); + builder.put(key, value == null ? LDValue.ofNull() : value); return this; } diff --git a/src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java b/src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java index 51fd536..ead8933 100644 --- a/src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java @@ -12,6 +12,7 @@ import static java.util.Collections.addAll; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; @SuppressWarnings("javadoc") public class LDValueArrayTest { @@ -95,6 +96,19 @@ public void primitiveValuesBehaveLikeEmptyArray() { } } + @Test + public void nullsInArrayAreAlwaysNullValueInstancesNotJavaNulls() { + LDValue a1 = LDValue.buildArray().add((LDValue)null).build(); + assertEquals(1, a1.size()); + assertNotNull(a1.get(0)); + assertEquals(LDValue.ofNull(), a1.get(0)); + + LDValue a2 = LDValue.parse("[null]"); + assertEquals(1, a2.size()); + assertNotNull(a2.get(0)); + assertEquals(LDValue.ofNull(), a2.get(0)); + } + @Test public void equalValuesAreEqual() { diff --git a/src/test/java/com/launchdarkly/sdk/LDValueObjectTest.java b/src/test/java/com/launchdarkly/sdk/LDValueObjectTest.java index d338fff..eba0f30 100644 --- a/src/test/java/com/launchdarkly/sdk/LDValueObjectTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueObjectTest.java @@ -13,6 +13,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.emptyIterable; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; @SuppressWarnings("javadoc") public class LDValueObjectTest { @@ -107,6 +108,19 @@ public void primitiveValuesBehaveLikeEmptyObject() { } } + @Test + public void nullsInObjectAreAlwaysNullValueInstancesNotJavaNulls() { + LDValue o1 = LDValue.buildObject().put("x", (LDValue)null).build(); + assertEquals(1, o1.size()); + assertNotNull(o1.get("x")); + assertEquals(LDValue.ofNull(), o1.get("x")); + + LDValue o2 = LDValue.parse("{\"x\":null}"); + assertEquals(1, o2.size()); + assertNotNull(o2.get("x")); + assertEquals(LDValue.ofNull(), o2.get("x")); + } + @Test public void equalValuesAreEqual() { From 9e200fbeefab03ebb394bdd62f34fc6cc7a8634e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 14:56:23 -0700 Subject: [PATCH 23/91] clean up some unnecessary coverage warnings --- build.gradle | 6 +----- src/main/java/com/launchdarkly/sdk/Helpers.java | 2 ++ .../java/com/launchdarkly/sdk/json/JsonSerialization.java | 2 ++ src/main/java/com/launchdarkly/sdk/json/LDGson.java | 1 + src/main/java/com/launchdarkly/sdk/json/LDJackson.java | 2 ++ 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/build.gradle b/build.gradle index f4fca2a..73ee6ad 100644 --- a/build.gradle +++ b/build.gradle @@ -88,13 +88,9 @@ jacocoTestCoverageVerification { "EvaluationReason.error(com.launchdarkly.sdk.EvaluationReason.ErrorKind)": 1, "EvaluationReasonTypeAdapter.parse(com.google.gson.stream.JsonReader)": 1, "EvaluationDetailTypeAdapterFactory.EvaluationDetailTypeAdapter.read(com.google.gson.stream.JsonReader)": 1, - "Helpers.Helpers()": 1, // abstract class constructor - known issue with Jacoco "LDValue.equals(java.lang.Object)": 1, "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)": 3, - "json.JsonSerialization.JsonSerialization()": 1, // abstract class constructor - "json.JsonSerialization.getDeserializableClasses()": -1, - "json.LDGson.LDGson()": 1, // abstract class constructor - "json.LDJackson.LDJackson()": 1 // abstract class constructor + "json.JsonSerialization.getDeserializableClasses()": -1 ] knownMissedLinesForMethods.each { partialSignature, maxMissedLines -> diff --git a/src/main/java/com/launchdarkly/sdk/Helpers.java b/src/main/java/com/launchdarkly/sdk/Helpers.java index e0c85b4..f602d0b 100644 --- a/src/main/java/com/launchdarkly/sdk/Helpers.java +++ b/src/main/java/com/launchdarkly/sdk/Helpers.java @@ -11,6 +11,8 @@ * library because the Android SDK does not have it. */ abstract class Helpers { + private Helpers() {} + // This implementation is much simpler than Guava's Iterables.transform() because it does not attempt // to support remove(). static Iterable transform(final Iterable source, final Function fn) { diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index ee8b8de..63b8169 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -31,6 +31,8 @@ * */ public abstract class JsonSerialization { + private JsonSerialization() {} + static final List> knownDeserializableClasses = new ArrayList<>(); static final Gson gson = new Gson(); diff --git a/src/main/java/com/launchdarkly/sdk/json/LDGson.java b/src/main/java/com/launchdarkly/sdk/json/LDGson.java index a5bc348..5878e4e 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDGson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDGson.java @@ -50,6 +50,7 @@ * exception if Gson is not in the caller's classpath. */ public abstract class LDGson { + private LDGson() {} // Implementation note: // The reason this class exists is the Java server-side SDK's issue with Gson interoperability due diff --git a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java index 2c26680..56541bd 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java @@ -42,6 +42,8 @@ * when deserializing, you will always get {@code EvaluationDetail}. */ public class LDJackson { + private LDJackson() {} + /** * Returns a Jackson {@code Module} that defines the correct serialization and deserialization * behavior for all LaunchDarkly SDK objects that implement {@link JsonSerializable}. From 8fbc7a0daf587f46c0c2fead3c5236f884f9f6a7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 26 May 2020 19:17:01 -0700 Subject: [PATCH 24/91] more convenient location for coverage reports --- .circleci/config.yml | 7 +++++-- CONTRIBUTING.md | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index ba4c792..7d3f159 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -70,7 +70,10 @@ jobs: steps: - run: name: Generate test coverage report - command: ./gradlew jacocoTestReport + command: | + ./gradlew jacocoTestReport + mkdir -p coverage/ + cp -r build/reports/jacoco/test/* ./coverage - run: name: Enforce test coverage command: ./gradlew jacocoTestCoverageVerification @@ -88,7 +91,7 @@ jobs: condition: <> steps: - store_artifacts: - path: build/reports/jacoco + path: coverage build-test-windows: executor: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 02aa5f4..be0f1ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,8 @@ This project is limited to Java 7 because it is used in both the LaunchDarkly se It is important to keep unit test coverage as close to 100% as possible in this project, since the SDK projects will not exercise every `java-sdk-common` method in their own unit tests. +You can view the latest code coverage report in CircleCI, as `coverage/html/index.html` in the artifacts for the "Java 11 - Linux - OpenJDK" job. You can also run the report locally with `./gradlew jacocoTestCoverage` and view `./build/reports/jacoco/test`. + Sometimes a gap in coverage is unavoidable, usually because the compiler requires us to provide a code path for some condition that in practice can't happen and can't be tested, or because of a known issue with the code coverage tool. Please handle all such cases as follows: * Mark the code with an explanatory comment beginning with "COVERAGE:". From fbe209be99b85d760a36e1b5648dee60561e6692 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 1 Jun 2020 12:28:07 -0700 Subject: [PATCH 25/91] remove rc1 from changelog --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef2e196..22477f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,3 @@ # Change log All notable changes to the project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). - -## [1.0.0-rc1] - 2020-04-29 - -Initial beta release, for the 5.0.0-rc1 release of the Java SDK. From ac4a2cd872effd51b0a5061f4361a9e08e01fdfe Mon Sep 17 00:00:00 2001 From: Ben Woskow <48036130+bwoskow-ld@users.noreply.github.com> Date: Wed, 3 Feb 2021 15:13:41 -0800 Subject: [PATCH 26/91] Removed the guides link --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index e1187d5..5c29584 100644 --- a/README.md +++ b/README.md @@ -26,4 +26,3 @@ See [Contributing](https://github.com/launchdarkly/dotnet-sdk-common/blob/master * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates - * [Feature Flagging Guide](https://github.com/launchdarkly/featureflags/ "Feature Flagging Guide") for best practices and strategies From 1cad8c5b89b88b0078aaf4ebe40632138278770e Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 30 Mar 2021 17:45:19 +0000 Subject: [PATCH 27/91] Use Android machine image for CircleCI Android tests. (#17) And update Android system image for testing to 21 (5.0 Lollipop). --- .circleci/config.yml | 67 +++++++++--------------- scripts/circleci/LICENSE | 21 -------- scripts/circleci/circle-android | 88 -------------------------------- scripts/install-android-tools.sh | 23 --------- scripts/run-android-tests.sh | 17 ------ scripts/start-android-env.sh | 8 --- scripts/started-android-env.sh | 9 ---- scripts/stop-android-env.sh | 5 -- 8 files changed, 25 insertions(+), 213 deletions(-) delete mode 100644 scripts/circleci/LICENSE delete mode 100755 scripts/circleci/circle-android delete mode 100755 scripts/install-android-tools.sh delete mode 100755 scripts/run-android-tests.sh delete mode 100755 scripts/start-android-env.sh delete mode 100755 scripts/started-android-env.sh delete mode 100755 scripts/stop-android-env.sh diff --git a/.circleci/config.yml b/.circleci/config.yml index 7d3f159..bcae287 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,6 +2,7 @@ version: 2.1 orbs: win: circleci/windows@1.0.0 + android: circleci/android@1.0 workflows: test: @@ -121,49 +122,31 @@ jobs: path: .\junit build-test-android: - # This is adapted from the CI build for android-client-sdk - macos: - xcode: "10.3.0" - shell: /bin/bash --login -eo pipefail - working_directory: ~/launchdarkly/android-client-sdk-private - environment: - TERM: dumb - QEMU_AUDIO_DRV: none - _JAVA_OPTIONS: "-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -Xms2048m -Xmx4096m" - GRADLE_OPTS: "-Dorg.gradle.daemon=false" - JVM_OPTS: -Xmx3200m - ANDROID_HOME: "/usr/local/share/android-sdk" - ANDROID_SDK_HOME: "/usr/local/share/android-sdk" - ANDROID_SDK_ROOT: "/usr/local/share/android-sdk" + executor: + name: android/android-machine + resource-class: large + steps: - checkout + + # What we want to do here is somewhat unusual: we want Android to run all of our tests from + # src/test/java, but run them in the Android emulator (to prove that we're only using Java + # APIs that our minimum Android API version supports). Normally, only tests in + # src/androidTest/java would be run that way. Also, Android needs a different JUnit test + # runner annotation on all of the test classes. So we can't just run the test code as-is. + # + # This step copies all the code from src/test/java into src/androidTest/java, except for the + # base class BaseTest.java, which is already defined in src/androidTest/java to provide the + # necessary test runner annotation. - run: - name: Install Android tools - command: ./scripts/install-android-tools.sh - - run: - name: Start Android environment - command: ./scripts/start-android-env.sh - background: true - timeout: 1200 - no_output_timeout: 20m - - run: - name: Wait for Android environment - command: ./scripts/started-android-env.sh - - run: - name: Run tests - command: ./scripts/run-android-tests.sh - no_output_timeout: 20m - - run: - name: Save test results - command: | - mkdir -p ~/test-results - cp -r ./build/outputs/androidTest-results/* ~/test-results/ - when: always - - run: - name: Stop Android environment - command: ./scripts/stop-android-env.sh - when: always + name: Copy tests + command: rsync -r ./src/test/java/ ./src/androidTest/java/ --exclude='BaseTest.java' + + - android/start-emulator-and-run-tests: + system-image: system-images;android-21;default;x86 + max-tries: 1 + post-emulator-launch-assemble-command: ./gradlew -b build-android.gradle :assembleAndroidTest + test-command: ./gradlew -b build-android.gradle :connectedAndroidTest + - store_test_results: - path: ~/test-results - - store_artifacts: - path: ~/artifacts + path: ./build/outputs/androidTest-results diff --git a/scripts/circleci/LICENSE b/scripts/circleci/LICENSE deleted file mode 100644 index 1311556..0000000 --- a/scripts/circleci/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2017-2019 Circle Internet Services, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/scripts/circleci/circle-android b/scripts/circleci/circle-android deleted file mode 100755 index 4524b60..0000000 --- a/scripts/circleci/circle-android +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python - -# See LICENSE file in this directory for copyright and license information -# Above LICENSE notice added 10/16/2019 by Gavin Whelan - -from sys import argv, exit, stdout -from time import sleep, time -from os import system -from subprocess import check_output, CalledProcessError -from threading import Thread, Event -from functools import partial - -class StoppableThread(Thread): - - def __init__(self): - super(StoppableThread, self).__init__() - self._stop_event = Event() - self.daemon = True - - def stopped(self): - return self._stop_event.is_set() - - def run(self): - while not self.stopped(): - stdout.write('.') - stdout.flush() - sleep(2) - - def stop(self): - self._stop_event.set() - -def shell_getprop(name): - try: - return check_output(['adb', 'shell', 'getprop', name]).strip() - except CalledProcessError as e: - return '' - -start_time = time() - -def wait_for(name, fn): - stdout.write('Waiting for %s' % name) - spinner = StoppableThread() - spinner.start() - stdout.flush() - while True: - if fn(): - spinner.stop() - time_taken = int(time() - start_time) - print('\n%s is ready after %d seconds' % (name, time_taken)) - break - sleep(1) - -def device_ready(): - return system('adb wait-for-device') == 0 - -def shell_ready(): - return system('adb shell true &> /dev/null') == 0 - -def prop_has_value(prop, value): - return shell_getprop(prop) == value - -def wait_for_sys_prop(name, prop, value): - # return shell_getprop('init.svc.bootanim') == 'stopped' - wait_for(name, partial(prop_has_value, prop, value)) - -usage = """ -%s, a collection of tools for CI with android. - -Usage: - %s wait-for-boot - wait for a device to fully boot. - (adb wait-for-device only waits for it to be ready for shell access). -""" - -if __name__ == "__main__": - - if len(argv) != 2 or argv[1] != 'wait-for-boot': - print(usage % (argv[0], argv[0])) - exit(0) - - wait_for('Device', device_ready) - wait_for('Shell', shell_ready) - wait_for_sys_prop('Boot animation complete', 'init.svc.bootanim', 'stopped') - wait_for_sys_prop('Boot animation exited', 'service.bootanim.exit', '1') - wait_for_sys_prop('System boot complete', 'sys.boot_completed', '1') - wait_for_sys_prop('GSM Ready', 'gsm.sim.state', 'READY') - #wait_for_sys_prop('init.svc.clear-bcb' ,'init.svc.clear-bcb', 'stopped') - - diff --git a/scripts/install-android-tools.sh b/scripts/install-android-tools.sh deleted file mode 100755 index 3df504d..0000000 --- a/scripts/install-android-tools.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash - -set -e -x -set +o pipefail # necessary because of how we're using "yes |" - -echo 'export PATH="$PATH:/usr/local/share/android-sdk/tools/bin"' >> $BASH_ENV -echo 'export PATH="$PATH:/usr/local/share/android-sdk/platform-tools"' >> $BASH_ENV - -HOMEBREW_NO_AUTO_UPDATE=1 brew tap homebrew/cask -HOMEBREW_NO_AUTO_UPDATE=1 brew cask install android-sdk - -yes | sdkmanager "platform-tools" \ - "platforms;android-19" \ - "extras;intel;Hardware_Accelerated_Execution_Manager" \ - "build-tools;26.0.2" \ - "system-images;android-19;default;x86" \ - "emulator" | grep -v = || true - -yes | sdkmanager --licenses - -echo no | avdmanager create avd -n ci-android-avd -f -k "system-images;android-19;default;x86" - -./gradlew -b build-android.gradle androidDependencies diff --git a/scripts/run-android-tests.sh b/scripts/run-android-tests.sh deleted file mode 100755 index 9592d84..0000000 --- a/scripts/run-android-tests.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -# What we want to do here is somewhat unusual: we want Android to run all of our tests from -# src/test/java, but run them in the Android emulator (to prove that we're only using Java APIs -# that our minimum Android API version supports). Normally, only tests in src/androidTest/java -# would be run that way. Also, Android needs a different JUnit test runner annotation on all of -# the test classes. So we can't just run the test code as-is. -# -# This script copies all the code from src/test/java into src/androidTest/java, except for the -# base class BaseTest.java, which is already defined in src/androidTest/java to provide the -# necessary test runner annotation. Then it runs the tests in the already-started emulator. - -set -x -e -o pipefail - -rsync -r ./src/test/java/ ./src/androidTest/java/ --exclude='BaseTest.java' - -./gradlew -b build-android.gradle :connectedAndroidTest --console=plain -PdisablePreDex diff --git a/scripts/start-android-env.sh b/scripts/start-android-env.sh deleted file mode 100755 index ced38d7..0000000 --- a/scripts/start-android-env.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -set -x -e -o pipefail - -unset ANDROID_NDK_HOME - -$ANDROID_HOME/emulator/emulator -avd ci-android-avd \ - -netdelay none -netspeed full -no-audio -no-window -no-snapshot -no-boot-anim diff --git a/scripts/started-android-env.sh b/scripts/started-android-env.sh deleted file mode 100755 index 9b009e7..0000000 --- a/scripts/started-android-env.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -set -x -e - -$(dirname $0)/circleci/circle-android wait-for-boot - -while ! adb shell getprop ro.build.version.sdk; do - sleep 1 -done diff --git a/scripts/stop-android-env.sh b/scripts/stop-android-env.sh deleted file mode 100755 index 31d085d..0000000 --- a/scripts/stop-android-env.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -x -e -o pipefail - -adb emu kill || true From 17f0b0bde1bf4472f120d968dfd31c71db58dd46 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 30 Mar 2021 11:33:06 -0700 Subject: [PATCH 28/91] improve Gson integration using reader/writer delegation + add LDValue helpers (#16) --- build.gradle | 5 +- .../EvaluationDetailTypeAdapterFactory.java | 1 - .../sdk/EvaluationReasonTypeAdapter.java | 6 +- .../java/com/launchdarkly/sdk/Helpers.java | 9 + .../java/com/launchdarkly/sdk/LDValue.java | 2 +- .../launchdarkly/sdk/LDValueTypeAdapter.java | 4 +- .../sdk/json/GsonReaderAdapter.java | 91 +++++++ .../sdk/json/GsonWriterAdapter.java | 140 +++++++++++ .../sdk/json/JsonSerialization.java | 10 - .../com/launchdarkly/sdk/json/LDGson.java | 237 ++++++++++++++++-- .../com/launchdarkly/sdk/LDValueTest.java | 28 +++ .../sdk/json/JsonTestHelpers.java | 17 ++ .../com/launchdarkly/sdk/json/LDGsonTest.java | 142 +++++++++++ .../sdk/json/LDUserJsonSerializationTest.java | 3 + .../json/LDValueJsonSerializationTest.java | 5 +- 15 files changed, 660 insertions(+), 40 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java create mode 100644 src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java create mode 100644 src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java diff --git a/build.gradle b/build.gradle index 73ee6ad..fffba90 100644 --- a/build.gradle +++ b/build.gradle @@ -89,8 +89,9 @@ jacocoTestCoverageVerification { "EvaluationReasonTypeAdapter.parse(com.google.gson.stream.JsonReader)": 1, "EvaluationDetailTypeAdapterFactory.EvaluationDetailTypeAdapter.read(com.google.gson.stream.JsonReader)": 1, "LDValue.equals(java.lang.Object)": 1, - "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)": 3, - "json.JsonSerialization.getDeserializableClasses()": -1 + "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)": 1, + "json.JsonSerialization.getDeserializableClasses()": -1, + "json.LDGson.LDTypeAdapter.write(com.google.gson.stream.JsonWriter, java.lang.Object)": 1 ] knownMissedLinesForMethods.each { partialSignature, maxMissedLines -> diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java index b6d66c7..a982856 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java @@ -87,6 +87,5 @@ public EvaluationDetail read(JsonReader in) throws IOException { return EvaluationDetail.fromValue(value, variation, reason); } - } } diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java index a505eb9..389f0d8 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java @@ -8,7 +8,7 @@ import java.io.IOException; -import static com.launchdarkly.sdk.Helpers.readNonNullableString; +import static com.launchdarkly.sdk.Helpers.readEnum; import static com.launchdarkly.sdk.Helpers.readNullableString; final class EvaluationReasonTypeAdapter extends TypeAdapter { @@ -29,7 +29,7 @@ static EvaluationReason parse(JsonReader reader) throws IOException { String key = reader.nextName(); switch (key) { // COVERAGE: may have spurious "branches missed" warning, see https://stackoverflow.com/questions/28013717/eclemma-branch-coverage-for-switch-7-of-19-missed case "kind": - kind = Enum.valueOf(EvaluationReason.Kind.class, readNonNullableString(reader)); + kind = readEnum(EvaluationReason.Kind.class, reader); break; case "ruleIndex": ruleIndex = reader.nextInt(); @@ -41,7 +41,7 @@ static EvaluationReason parse(JsonReader reader) throws IOException { prereqKey = reader.nextString(); break; case "errorKind": - errorKind = Enum.valueOf(EvaluationReason.ErrorKind.class, readNonNullableString(reader)); + errorKind = readEnum(EvaluationReason.ErrorKind.class, reader); break; default: reader.skipValue(); // ignore any unexpected property diff --git a/src/main/java/com/launchdarkly/sdk/Helpers.java b/src/main/java/com/launchdarkly/sdk/Helpers.java index f602d0b..aafe4f7 100644 --- a/src/main/java/com/launchdarkly/sdk/Helpers.java +++ b/src/main/java/com/launchdarkly/sdk/Helpers.java @@ -56,4 +56,13 @@ static String readNonNullableString(JsonReader reader) throws IOException { throw new JsonParseException("expected string value"); } } + + static > T readEnum(Class enumClass, JsonReader reader) throws IOException { + String s = readNonNullableString(reader); + try { + return Enum.valueOf(enumClass, s); + } catch (IllegalArgumentException e) { + throw new JsonParseException(String.format("unsupported value \"{}\" for {}", s, enumClass)); + } + } } diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index b358025..60394cb 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -176,7 +176,7 @@ public static ObjectBuilder buildObject() { */ public static LDValue parse(String json) { try { - return JsonSerialization.deserialize(json, LDValue.class); + return LDValue.normalize(JsonSerialization.deserialize(json, LDValue.class)); } catch (SerializationException e) { throw new RuntimeException(e); } diff --git a/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java index bd5f419..88eb956 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java @@ -35,7 +35,6 @@ public LDValue read(JsonReader reader) throws IOException { case BOOLEAN: return LDValue.of(reader.nextBoolean()); case NULL: - // COVERAGE: this branch won't be reached because Gson does not call the TypeAdapter when there's a null. reader.nextNull(); return LDValue.ofNull(); case NUMBER: @@ -43,7 +42,8 @@ public LDValue read(JsonReader reader) throws IOException { case STRING: return LDValue.of(reader.nextString()); default: - // COVERAGE: this branch won't be reached because Gson does not call the TypeAdapter if the next token isn't well-formed JSON. + // COVERAGE: this branch won't be reached because Gson does not call the TypeAdapter if the next token + // isn't any of the above. return null; } } diff --git a/src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java b/src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java new file mode 100644 index 0000000..e96fc28 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java @@ -0,0 +1,91 @@ +package com.launchdarkly.sdk.json; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; + +import java.io.CharArrayReader; +import java.io.IOException; +import java.io.Reader; + +// This type is a bridge between the Gson classes on the application classpath and the Gson classes +// that are used internally. +// +// In some SDK distributions, there is an internal set of Gson classes that have modified (shaded) +// class names, to ensure that the SDK can use its own version of Gson without conflicting with the +// application. If so, all references to Gson classes in the SDK code will be transformed to the +// shaded class names *except* within the LDGson class. This means that our Gson TypeAdapters can't +// interact directly with a JsonReader or JsonWriter that is provided by the application. +// +// GsonReaderAdapter and GsonWriterAdapter, since they are declared outside of the LDGson class, +// *will* have all Gson types in their class/method signatures shaded if we are using shading. +// Therefore, they can be used with our internal Gson logic. But the actual implementation of their +// methods is done by a subclass that is an inner class of LDGson-- so, that class can interact +// with unshaded Gson classes provided by the application. +// +// So, if all com.google.gson classes are being shaded to com.launchdarkly.shaded.com.google.gson, +// then the base class of GsonReaderAdapter is com.launchdarkly.shaded.com.google.gson.JsonReader; +// the class LDGson.DelegatingJsonReaderAdapter is a GsonReaderAdapter, so it is also a +// com.launchdarkly.shaded.com.google.gson.JsonReader; but references to JsonReader within the +// implementation of LDGson.DelegatingJsonReaderAdapter are to com.google.json.JsonReader. +// +// In SDK distributions that do not use shading, these types are not really necessary, but their +// overhead is minimal so we use them in all cases. +abstract class GsonReaderAdapter extends JsonReader { + private static final JsonToken[] TOKEN_VALUES = JsonToken.values(); + + GsonReaderAdapter() { + super(makeStubReader()); + } + + private static final Reader makeStubReader() { + // The JsonReader constructor requires a non-null Reader, but we won't actually be using it. + // Unfortunately Java 7 doesn't implement a completely no-op Reader. + return new CharArrayReader(new char[0]); + } + + @Override + abstract public void beginArray() throws IOException; + + @Override + abstract public void beginObject() throws IOException; + + @Override + abstract public void endArray() throws IOException; + + @Override + abstract public void endObject() throws IOException; + + @Override + abstract public boolean hasNext() throws IOException; + + @Override + abstract public boolean nextBoolean() throws IOException; + + @Override + abstract public double nextDouble() throws IOException; + + @Override + abstract public int nextInt() throws IOException; + + @Override + abstract public long nextLong() throws IOException; + + @Override + abstract public String nextName() throws IOException; + + @Override + abstract public void nextNull() throws IOException; + + @Override + abstract public String nextString() throws IOException; + + @Override + public JsonToken peek() throws IOException { + return TOKEN_VALUES[peekInternal()]; + } + + @Override + abstract public void skipValue() throws IOException; + + abstract protected int peekInternal() throws IOException; // should return the ordinal of the JsonToken enum +} diff --git a/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java b/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java new file mode 100644 index 0000000..29e4236 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java @@ -0,0 +1,140 @@ +package com.launchdarkly.sdk.json; + +import com.google.gson.stream.JsonWriter; + +import java.io.CharArrayWriter; +import java.io.IOException; +import java.io.Writer; + +// This type is a bridge between the Gson classes on the application classpath and the Gson classes +// that are used internally. +// +// In some SDK distributions, there is an internal set of Gson classes that have modified (shaded) +// class names, to ensure that the SDK can use its own version of Gson without conflicting with the +// application. If so, all references to Gson classes in the SDK code will be transformed to the +// shaded class names *except* within the LDGson class. This means that our Gson TypeAdapters can't +// interact directly with a JsonReader or JsonWriter that is provided by the application. +// +// GsonReaderAdapter and GsonWriterAdapter, since they are declared outside of the LDGson class, +// *will* have all Gson types in their class/method signatures shaded if we are using shading. +// Therefore, they can be used with our internal Gson logic. But the actual implementation of their +// methods is done by a subclass that is an inner class of LDGson-- so, that class can interact +// with unshaded Gson classes provided by the application. +// +// So, if all com.google.gson classes are being shaded to com.launchdarkly.shaded.com.google.gson, +// then the base class of GsonWriterAdapter is com.launchdarkly.shaded.com.google.gson.JsonWriter; +// the class LDGson.DelegatingJsonWriterAdapter is a GsonWriterAdapter, so it is also a +// com.launchdarkly.shaded.com.google.gson.JsonWriter; but references to JsonWriter within the +// implementation of LDGson.DelegatingJsonWriterAdapter are to com.google.json.JsonWriter. +// +// In SDK distributions that do not use shading, these types are not really necessary, but their +// overhead is minimal so we use them in all cases. +abstract class GsonWriterAdapter extends JsonWriter { + GsonWriterAdapter() { + super(makeStubWriter()); + } + + private static final Writer makeStubWriter() { + // The JsonWriter constructor requires a non-null Writer, but we won't actually be using it. + // Unfortunately Java 7 doesn't implement a completely no-op Writer. + return new CharArrayWriter(0); + } + + @Override + public JsonWriter beginArray() throws IOException { + beginArrayInternal(); + return this; + } + + @Override + public JsonWriter beginObject() throws IOException { + beginObjectInternal(); + return this; + } + + @Override + public JsonWriter endArray() throws IOException { + endArrayInternal(); + return this; + } + + @Override + public JsonWriter endObject() throws IOException { + endObjectInternal(); + return this; + } + + @Override + public JsonWriter jsonValue(String value) throws IOException { + jsonValueInternal(value); + return this; + } + + @Override + public JsonWriter name(String name) throws IOException { + nameInternal(name); + return this; + } + + @Override + public JsonWriter nullValue() throws IOException { + valueInternalNull(); + return this; + } + + @Override + public JsonWriter value(boolean value) throws IOException { + valueInternalBool(value); + return this; + } + + @Override + public JsonWriter value(Boolean value) throws IOException { + if (value == null) { + valueInternalNull(); + } else { + valueInternalBool(value.booleanValue()); + } + return this; + } + + @Override + public JsonWriter value(double value) throws IOException { + valueInternalDouble(value); + return this; + } + + @Override + public JsonWriter value(long value) throws IOException { + valueInternalLong(value); + return this; + } + + @Override + public JsonWriter value(Number value) throws IOException { + valueInternalNumber(value); + return this; + } + + @Override + public JsonWriter value(String value) throws IOException { + valueInternalString(value); + return this; + } + + @Override + public void close() throws IOException {} + + protected abstract void beginArrayInternal() throws IOException; + protected abstract void beginObjectInternal() throws IOException; + protected abstract void endArrayInternal() throws IOException; + protected abstract void endObjectInternal() throws IOException; + protected abstract void jsonValueInternal(String value) throws IOException; + protected abstract void nameInternal(String name) throws IOException; + protected abstract void valueInternalNull() throws IOException; + protected abstract void valueInternalBool(boolean value) throws IOException; + protected abstract void valueInternalDouble(double value) throws IOException; + protected abstract void valueInternalLong(long value) throws IOException; + protected abstract void valueInternalNumber(Number value) throws IOException; + protected abstract void valueInternalString(String value) throws IOException; +} diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index 63b8169..bdab08b 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -8,7 +8,6 @@ import com.launchdarkly.sdk.UserAttribute; import java.lang.reflect.Method; -import java.lang.reflect.Type; import java.util.ArrayList; import java.util.List; @@ -86,15 +85,6 @@ static T deserializeInternal(String json, Class objectClass) throws Seria } } - // Used internally from LDGson - static T deserializeInternalGson(String json, Type objectType) throws SerializationException { - try { - return gson.fromJson(json, objectType); - } catch (Exception e) { - throw new SerializationException(e); - } - } - /** * Internal method to return all of the classes that we should have a custom deserializer for. *

diff --git a/src/main/java/com/launchdarkly/sdk/json/LDGson.java b/src/main/java/com/launchdarkly/sdk/json/LDGson.java index 5878e4e..a762b16 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDGson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDGson.java @@ -1,8 +1,11 @@ package com.launchdarkly.sdk.json; import com.google.gson.Gson; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; -import com.google.gson.JsonParseException; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; import com.google.gson.reflect.TypeToken; @@ -13,6 +16,8 @@ import java.io.IOException; import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.Map; /** * A helper class for interoperability with application code that uses Gson. @@ -50,6 +55,9 @@ * exception if Gson is not in the caller's classpath. */ public abstract class LDGson { + private static final JsonElement JSONELEMENT_TRUE = new JsonPrimitive(true); + private static final JsonElement JSONELEMENT_FALSE = new JsonPrimitive(false); + private LDGson() {} // Implementation note: @@ -79,6 +87,57 @@ public static TypeAdapterFactory typeAdapters() { return LDTypeAdapterFactory.INSTANCE; } + /** + * Returns a Gson {@code JsonElement} that is equivalent to the specified {@link LDValue}. + *

+ * This is slightly more efficient than using {@code Gson.toJsonTree()}. + * + * @param value an {@link LDValue} ({@code null} is treated as equivalent to {@link LDValue#ofNull()}) + * @return a Gson {@code JsonElement} (may be a {@code JsonNull} but will never be {@code null}) + */ + public static JsonElement valueToJsonElement(LDValue value) { + if (value == null) { + return JsonNull.INSTANCE; + } + switch (value.getType()) { + case BOOLEAN: + return value.booleanValue() ? JSONELEMENT_TRUE : JSONELEMENT_FALSE; + case NUMBER: + return new JsonPrimitive(value.doubleValue()); + case STRING: + return value.stringValue() == null ? JsonNull.INSTANCE : new JsonPrimitive(value.stringValue()); + case ARRAY: + JsonArray a = new JsonArray(); + for (LDValue e: value.values()) { + a.add(valueToJsonElement(e)); + } + return a; + case OBJECT: + JsonObject o = new JsonObject(); + for (String k: value.keys()) { + o.add(k, valueToJsonElement(value.get(k))); + } + return o; + default: + return JsonNull.INSTANCE; + } + } + + /** + * Convenience method for converting a map of {@link LDValue} values to a map of Gson {@codeJsonElement}s. + * + * @param type of the map's keys + * @param valueMap a map containing {@link LDValue} values + * @return an equivalent map containing Gson {@code JsonElement} values + */ + public static Map valueMapToJsonElementMap(Map valueMap) { + Map ret = new HashMap<>(valueMap.size()); + for (Map.Entry e: valueMap.entrySet()) { + ret.put(e.getKey(), valueToJsonElement(e.getValue())); + } + return ret; + } + private static class LDTypeAdapterFactory implements TypeAdapterFactory { // Note that this static initializer will only run if application code actually references LDGson. private static LDTypeAdapterFactory INSTANCE = new LDTypeAdapterFactory(); @@ -86,41 +145,181 @@ private static class LDTypeAdapterFactory implements TypeAdapterFactory { @Override public TypeAdapter create(Gson gson, TypeToken type) { if (JsonSerializable.class.isAssignableFrom(type.getRawType())) { - return new LDTypeAdapter(gson, type.getType()); + return new LDTypeAdapter(type.getType()); } return null; } } private static class LDTypeAdapter extends TypeAdapter { - private final Gson gson; private final Type objectType; - LDTypeAdapter(Gson gson, Type objectType) { - this.gson = gson; + LDTypeAdapter(Type objectType) { this.objectType = objectType; } @Override public void write(JsonWriter out, T value) throws IOException { - String json = JsonSerialization.serializeInternal(value); - out.jsonValue(json); + if (value == null) { + // COVERAGE: we don't expect this to ever happen, since Gson normally doesn't bother to call + // the type adapter for any null value; it's just a sanity check. + out.nullValue(); + } else { + JsonSerialization.gson.toJson(value, value.getClass(), new DelegatingJsonWriterAdapter(out)); + } } @Override public T read(JsonReader in) throws IOException { - // This implementation is inefficient because we can't assume our internal Gson instance can - // use this JsonReader directly; instead we have to read the next JSON value, convert it to a - // string, and then ask our JsonSerialization to parse it back from a string. - JsonElement jsonTree = gson.fromJson(in, JsonElement.class); - String jsonString = gson.toJson(jsonTree); - try { - // Calling the Gson overload that takes a Type rather than a Class (even though a Class *is* a - // Type) allows it to take generic type parameters into account for EvaluationDetail. - return JsonSerialization.deserializeInternalGson(jsonString, objectType); - } catch (SerializationException e) { - throw new JsonParseException(e.getCause()); - } + return JsonSerialization.gson.fromJson(new DelegatingJsonReaderAdapter(in), objectType); + } + } + + // See comments on GsonReaderAdapter for the reason this type exists. + static class DelegatingJsonReaderAdapter extends GsonReaderAdapter { + private final JsonReader reader; + + DelegatingJsonReaderAdapter(JsonReader reader) { + this.reader = reader; + } + + @Override + public void beginArray() throws IOException { + reader.beginArray(); + } + + @Override + public void beginObject() throws IOException { + reader.beginObject(); + } + + @Override + public void endArray() throws IOException { + reader.endArray(); + } + + @Override + public void endObject() throws IOException { + reader.endObject(); + } + + @Override + public boolean hasNext() throws IOException { + return reader.hasNext(); + } + + @Override + public boolean nextBoolean() throws IOException { + return reader.nextBoolean(); + } + + @Override + public double nextDouble() throws IOException { + return reader.nextDouble(); + } + + @Override + public int nextInt() throws IOException { + return reader.nextInt(); + } + + @Override + public long nextLong() throws IOException { + return reader.nextLong(); + } + + @Override + public String nextName() throws IOException { + return reader.nextName(); + } + + @Override + public void nextNull() throws IOException { + reader.nextNull(); + } + + @Override + public String nextString() throws IOException { + return reader.nextString(); + } + + @Override + public void skipValue() throws IOException { + reader.skipValue(); + } + + @Override + protected int peekInternal() throws IOException { + return reader.peek().ordinal(); + } + } + + // See comments on GsonWriterAdapter for the reason this type exists. + static class DelegatingJsonWriterAdapter extends GsonWriterAdapter { + private final JsonWriter writer; + + DelegatingJsonWriterAdapter(JsonWriter writer) { + this.writer = writer; + } + + @Override + protected void beginArrayInternal() throws IOException { + writer.beginArray(); + } + + @Override + protected void beginObjectInternal() throws IOException { + writer.beginObject(); + } + + @Override + protected void endArrayInternal() throws IOException { + writer.endArray(); + } + + @Override + protected void endObjectInternal() throws IOException { + writer.endObject(); + } + + @Override + protected void jsonValueInternal(String value) throws IOException { + writer.jsonValue(value); + } + + @Override + protected void nameInternal(String name) throws IOException { + writer.name(name); + } + + @Override + protected void valueInternalNull() throws IOException { + writer.nullValue(); + } + + @Override + protected void valueInternalBool(boolean value) throws IOException { + writer.value(value); + } + + @Override + protected void valueInternalDouble(double value) throws IOException { + writer.value(value); + } + + @Override + protected void valueInternalLong(long value) throws IOException { + writer.value(value); + } + + @Override + protected void valueInternalNumber(Number value) throws IOException { + writer.value(value); + } + + @Override + protected void valueInternalString(String value) throws IOException { + writer.value(value); } } } diff --git a/src/test/java/com/launchdarkly/sdk/LDValueTest.java b/src/test/java/com/launchdarkly/sdk/LDValueTest.java index af49bd9..ee3df4a 100644 --- a/src/test/java/com/launchdarkly/sdk/LDValueTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueTest.java @@ -1,9 +1,13 @@ package com.launchdarkly.sdk; +import com.google.gson.JsonParseException; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.MalformedJsonException; import com.launchdarkly.sdk.json.SerializationException; import org.junit.Test; +import java.io.StringReader; import java.util.List; import static java.util.Arrays.asList; @@ -258,4 +262,28 @@ public void parseThrowsRuntimeExceptionForMalformedJson() { assertThat(e.getCause(), instanceOf(SerializationException.class)); } } + + @Test + public void testLowLevelTypeAdapter() throws Exception { + // This test ensures full test coverage of LDValueTypeAdapter code paths that might not + // be exercised indirectly by other tests. + verifyTypeAdapterRead("null", LDValue.ofNull()); + verifyTypeAdapterRead("true", LDValue.of(true)); + verifyTypeAdapterRead("1", LDValue.of(1)); + verifyTypeAdapterRead("\"x\"", LDValue.of("x")); + verifyTypeAdapterRead("[1,2]", LDValue.buildArray().add(1).add(2).build()); + verifyTypeAdapterRead("{\"a\":1}", LDValue.buildObject().put("a", 1).build()); + + try (JsonReader r = new JsonReader(new StringReader("]"))) { + try { + LDValueTypeAdapter.INSTANCE.read(r); + } catch (MalformedJsonException e) {} + } + } + + private static void verifyTypeAdapterRead(String jsonString, LDValue expectedValue) throws Exception { + try (JsonReader r = new JsonReader(new StringReader(jsonString))) { + assertEquals(expectedValue, LDValueTypeAdapter.INSTANCE.read(r)); + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java index 96fa087..f82c9d9 100644 --- a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java @@ -87,4 +87,21 @@ public static void assertJsonEquals(String expectedJsonString, String actualJson public static JsonElement parseElement(String jsonString) { return JsonSerialization.gson.fromJson(jsonString, JsonElement.class); } + + public static LDValue basicArrayValue() { + return LDValue.buildArray().add(2).add("x").build(); + } + + public static LDValue basicObjectValue() { + return LDValue.buildObject().put("x", 2).build(); + } + + public static LDValue nestedArrayValue() { + return LDValue.buildArray().add(3).add(basicArrayValue()).add(4).add(basicObjectValue()).add(5).build(); + } + + public static LDValue nestedObjectValue() { + return LDValue.buildObject().put("a", 1).put("b", basicArrayValue()).put("c", 2) + .put("d", basicObjectValue()).put("e", 5).build(); + } } diff --git a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java new file mode 100644 index 0000000..ce9f7f2 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java @@ -0,0 +1,142 @@ +package com.launchdarkly.sdk.json; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import org.junit.Test; + +import java.io.StringReader; +import java.io.StringWriter; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") +public class LDGsonTest { + // Note that these unit tests don't fully prove that our Gson integration works as intended + // in SDK distributions that shade the Gson classes, because the tests for this project are + // run on the unmodified code with real Gson classes in the classpath. SDKs that use shading + // must implement their own unit tests, run against SDK code that has had shading applied, + // to verify that these methods still work in that environment. However, those tests do not + // need to be as detailed as these in terms of covering all of the various JSON types; if + // the methods work for anything, they should work for everything, since the issue there is + // just whether the correct Gson package names are being used. + + @Test + public void valueToJsonElement() { + verifyValueSerialization(LDValue.ofNull()); + verifyValueSerialization(LDValue.of(true)); + verifyValueSerialization(LDValue.of(false)); + verifyValueSerialization(LDValue.of("x")); + verifyValueSerialization(LDValue.of("say \"hello\"")); + verifyValueSerialization(LDValue.of(2)); + verifyValueSerialization(LDValue.of(2.5f)); + verifyValueSerialization(JsonTestHelpers.nestedArrayValue()); + verifyValueSerialization(JsonTestHelpers.nestedObjectValue()); + assertEquals(JsonNull.INSTANCE, LDGson.valueToJsonElement(null)); + } + + @Test + public void valueMapToJsonElementMap() { + Map m1 = new HashMap<>(); + m1.put("a", LDValue.of(true)); + m1.put("b", LDValue.of(1)); + String js1 = JsonSerialization.gson.toJson(m1); + + Map m2 = LDGson.valueMapToJsonElementMap(m1); + String js2 = JsonSerialization.gson.toJson(m2); + JsonTestHelpers.assertJsonEquals(js1, js2); + } + + @Test + public void complexObjectToJsonTree() { + LDUser user = new LDUser.Builder("userkey").name("name") + .custom("attr1", LDValue.ofNull()) + .custom("atrt2", LDValue.of(true)) + .custom("attr3", LDValue.of(false)) + .custom("attr4", LDValue.of(0)) + .custom("attr5", LDValue.of(1)) + .custom("attr6", LDValue.of("")) + .custom("attr7", LDValue.of("x")) + .custom("attr8", JsonTestHelpers.nestedArrayValue()) + .custom("attr9", JsonTestHelpers.nestedObjectValue()) + .build(); + JsonElement j = JsonTestHelpers.configureGson().toJsonTree(user); + String js = JsonSerialization.gson.toJson(j); + assertEquals(LDValue.parse(JsonSerialization.serialize(user)), LDValue.parse(js)); + } + + @Test + public void testInternalReaderAdapter() throws Exception { + // This and testInternalWriterAdapter verify that all of our reader/writer delegation + // methods work as expected, regardless of whether or not they are exercised indirectly + // by our other unit tests. + String json = "[null,false,true,1,2,3,\"x\",{\"a\":false}]"; + try (StringReader sr = new StringReader(json)) { + try (JsonReader jr0 = new JsonReader(sr)) { + try (JsonReader jr = new LDGson.DelegatingJsonReaderAdapter(jr0)) { + jr.beginArray(); + assertEquals(true, jr.hasNext()); + jr.nextNull(); + assertEquals(JsonToken.BOOLEAN, jr.peek()); + jr.skipValue(); + assertEquals(true, jr.nextBoolean()); + assertEquals(1d, jr.nextDouble(), 0); + assertEquals(2, jr.nextInt()); + assertEquals(3, jr.nextLong()); + assertEquals("x", jr.nextString()); + jr.beginObject(); + assertEquals("a", jr.nextName()); + assertEquals(false, jr.nextBoolean()); + jr.endObject(); + jr.endArray(); + } + } + } + } + + @Test + public void testInternalWriterAdapter() throws Exception { + try (StringWriter sw = new StringWriter()) { + try (JsonWriter jw0 = new JsonWriter(sw)) { + try (JsonWriter jw = new LDGson.DelegatingJsonWriterAdapter(jw0)) { + jw.beginArray(); + jw.nullValue(); + jw.value(true); + jw.value(Boolean.valueOf(true)); + jw.value((Boolean)null); + jw.value((double)1); + jw.value((long)2); + jw.value(Float.valueOf(3)); + jw.value("x"); + jw.beginObject(); + jw.name("a"); + jw.value(false); + jw.endObject(); + jw.jsonValue("123"); + jw.endArray(); + jw.flush(); + } + } + String expected = "[null,true,true,null,1,2,3,\"x\",{\"a\":false},123]"; + JsonTestHelpers.assertJsonEquals(expected, sw.toString()); + } + } + + static void verifyValueSerialization(LDValue value) { + JsonElement j1 = LDGson.valueToJsonElement(value); + String js1 = JsonSerialization.gson.toJson(j1); + JsonTestHelpers.assertJsonEquals(value.toJsonString(), js1); + + JsonElement j2 = JsonTestHelpers.configureGson().toJsonTree(value); + String js2 = JsonSerialization.gson.toJson(j2); + JsonTestHelpers.assertJsonEquals(value.toJsonString(), js2); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java index 18c7334..95370da 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java @@ -10,6 +10,7 @@ import static com.launchdarkly.sdk.TestHelpers.builtInAttributes; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; @SuppressWarnings("javadoc") @@ -21,6 +22,8 @@ public void minimalJsonEncoding() throws Exception { verifyDeserializeInvalidJson(LDUser.class, "3"); verifyDeserializeInvalidJson(LDUser.class, "{\"key\":\"userkey\",\"name\":3"); + + verifySerialize((LDUser)null, "null"); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java index 55e5511..ac955be 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java @@ -16,6 +16,7 @@ public class LDValueJsonSerializationTest extends BaseTest { @Test public void jsonEncodingForNull() throws Exception { verifySerialize(LDValue.ofNull(), "null"); + verifySerialize((LDValue)null, "null"); } @Test @@ -27,8 +28,8 @@ public void jsonEncodingForNonNullValues() throws Exception { verifyValueSerialization(LDValue.of(2), "2"); verifyValueSerialization(LDValue.of(2.5f), "2.5"); verifyValueSerialization(LDValue.of(2.5d), "2.5"); - verifyValueSerialization(LDValue.buildArray().add(2).add("x").build(), "[2,\"x\"]"); - verifyValueSerialization(LDValue.buildObject().put("x", 2).build(), "{\"x\":2}"); + verifyValueSerialization(JsonTestHelpers.basicArrayValue(), "[2,\"x\"]"); + verifyValueSerialization(JsonTestHelpers.basicObjectValue(), "{\"x\":2}"); verifyDeserializeInvalidJson(LDValue.class, "]"); } From 96931b3571b6f6f29633f80676cd4a3848b855ef Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 30 Mar 2021 14:40:49 -0700 Subject: [PATCH 29/91] add LDValue.arrayOf() + misc javadoc fixes --- .../java/com/launchdarkly/sdk/LDValue.java | 20 +++++++++++++++++-- .../com/launchdarkly/sdk/LDValueArray.java | 3 +-- .../launchdarkly/sdk/LDValueArrayTest.java | 13 ++++++++++-- .../com/launchdarkly/sdk/LDValueTest.java | 1 - 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index 60394cb..956dc7f 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.json.SerializationException; import java.io.IOException; +import java.util.Arrays; import java.util.Map; import static com.launchdarkly.sdk.Helpers.transform; @@ -133,9 +134,9 @@ public static LDValue of(String value) { } /** - * Starts building an array value. + * Starts building an array value. The elements can be of any type supported by LDValue. *


-   *     LDValue arrayOfInts = LDValue.buildArray().add(LDValue.int(1), LDValue.int(2)).build():
+   *     LDValue arrayOfInts = LDValue.buildArray().add(2).add("three").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...)}. @@ -146,6 +147,21 @@ public static ArrayBuilder buildArray() { return new ArrayBuilder(); } + /** + * Creates an array value from the specified values. The elements can be of any type supported by LDValue. + *

+   *     LDValue arrayOfMixedValues = LDValue.arrayOf(LDValue.of(2), LDValue.of("three"));
+   * 
+ * If the values are all of the same type, you may also use {@link LDValue.Converter#arrayFrom(Iterable)} + * or {@link LDValue.Converter#arrayOf(Object...)}. + * + * @param values any number of values + * @return an immutable array value + */ + public static LDValue arrayOf(LDValue... values) { + return LDValueArray.fromList(values == null ? null : Arrays.asList(values)); + } + /** * Starts building an object value. *

diff --git a/src/main/java/com/launchdarkly/sdk/LDValueArray.java b/src/main/java/com/launchdarkly/sdk/LDValueArray.java
index 3c84fc5..c9675fa 100644
--- a/src/main/java/com/launchdarkly/sdk/LDValueArray.java
+++ b/src/main/java/com/launchdarkly/sdk/LDValueArray.java
@@ -13,10 +13,9 @@
 final class LDValueArray extends LDValue {
   private static final LDValueArray EMPTY = new LDValueArray(Collections.emptyList());
   private final List list;
-  // Note that this is not  
 
   static LDValueArray fromList(List list) {
-    return list.isEmpty() ? EMPTY : new LDValueArray(list);
+    return list == null || list.isEmpty() ? EMPTY : new LDValueArray(list);
   }
 
   private LDValueArray(List list) {
diff --git a/src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java b/src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java
index ead8933..bb4b2e5 100644
--- a/src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java
+++ b/src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java
@@ -110,8 +110,7 @@ public void nullsInArrayAreAlwaysNullValueInstancesNotJavaNulls() {
   }
   
   @Test
-  public void equalValuesAreEqual()
-  {
+  public void equalValuesAreEqual() {
     List> testValues = asList(
         asList(LDValue.buildArray().build(), LDValue.buildArray().build()),
         asList(LDValue.buildArray().add("a").build(), LDValue.buildArray().add("a").build()),
@@ -127,6 +126,16 @@ public void equalValuesAreEqual()
     TestHelpers.doEqualityTests(testValues);
   }
   
+  @Test
+  public void arrayOf() {
+    assertEquals(LDValue.buildArray().add(LDValue.of(2)).add(LDValue.of("three")).build(),
+          LDValue.arrayOf(LDValue.of(2), LDValue.of("three")));
+
+    assertEquals(LDValue.buildArray().build(), LDValue.arrayOf());
+
+    assertEquals(LDValue.buildArray().build(), LDValue.arrayOf((LDValue[])null));
+  }
+  
   @Test
   public void testTypeConversions() {
     testTypeConversion(LDValue.Convert.Boolean, new Boolean[] { true, false }, false, LDValue.of(true), LDValue.of(false));
diff --git a/src/test/java/com/launchdarkly/sdk/LDValueTest.java b/src/test/java/com/launchdarkly/sdk/LDValueTest.java
index ee3df4a..39bdac9 100644
--- a/src/test/java/com/launchdarkly/sdk/LDValueTest.java
+++ b/src/test/java/com/launchdarkly/sdk/LDValueTest.java
@@ -1,6 +1,5 @@
 package com.launchdarkly.sdk;
 
-import com.google.gson.JsonParseException;
 import com.google.gson.stream.JsonReader;
 import com.google.gson.stream.MalformedJsonException;
 import com.launchdarkly.sdk.json.SerializationException;

From 41058b9d5614fa6024acb430e3bb18c83eb562ec Mon Sep 17 00:00:00 2001
From: "Robert J. Neal" 
Date: Fri, 2 Apr 2021 17:59:37 -0700
Subject: [PATCH 30/91] Add inExperiment attribute to FALLTHROUGH and
 RULE_MATCH reasons

---
 .../launchdarkly/sdk/EvaluationReason.java    | 67 ++++++++++++++++---
 .../sdk/EvaluationReasonTypeAdapter.java      | 14 +++-
 .../sdk/EvaluationReasonTest.java             | 10 ++-
 ...EvaluationReasonJsonSerializationTest.java | 16 ++++-
 4 files changed, 90 insertions(+), 17 deletions(-)

diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
index 17e0084..dab4f94 100644
--- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
+++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
@@ -27,6 +27,9 @@
  */
 @JsonAdapter(EvaluationReasonTypeAdapter.class)
 public final class EvaluationReason implements JsonSerializable {
+  private static boolean IN_EXPERIMENT = true;
+  private static boolean NOT_IN_EXPERIMENT = false;
+
   /**
    * Enumerated type defining the possible values of {@link EvaluationReason#getKind()}.
    */
@@ -96,6 +99,7 @@ public static enum ErrorKind {
   // static instances to avoid repeatedly allocating reasons for the same parameters
   private static final EvaluationReason OFF_INSTANCE = new EvaluationReason(Kind.OFF);
   private static final EvaluationReason FALLTHROUGH_INSTANCE = new EvaluationReason(Kind.FALLTHROUGH);
+  private static final EvaluationReason FALLTHROUGH_INSTANCE_IN_EXPERIMENT = new EvaluationReason(Kind.FALLTHROUGH, IN_EXPERIMENT);
   private static final EvaluationReason TARGET_MATCH_INSTANCE = new EvaluationReason(Kind.TARGET_MATCH);
   private static final EvaluationReason ERROR_CLIENT_NOT_READY = new EvaluationReason(ErrorKind.CLIENT_NOT_READY, null);
   private static final EvaluationReason ERROR_FLAG_NOT_FOUND = new EvaluationReason(ErrorKind.FLAG_NOT_FOUND, null);
@@ -108,25 +112,31 @@ public static enum ErrorKind {
   private final int ruleIndex;
   private final String ruleId;
   private final String prerequisiteKey;
+  private final boolean inExperiment;
   private final ErrorKind errorKind;
   private final Exception exception;
   
-  private EvaluationReason(Kind kind, int ruleIndex, String ruleId, String prerequisiteKey,
+  private EvaluationReason(Kind kind, int ruleIndex, String ruleId, String prerequisiteKey, boolean inExperiment,
       ErrorKind errorKind, Exception exception) {
     this.kind = kind;
     this.ruleIndex = ruleIndex;
     this.ruleId = ruleId;
     this.prerequisiteKey = prerequisiteKey;
+    this.inExperiment = inExperiment;
     this.errorKind = errorKind;
     this.exception = exception;
   }
   
   private EvaluationReason(Kind kind) {
-    this(kind, -1, null, null, null, null);
+    this(kind, -1, null, null, NOT_IN_EXPERIMENT, null, null);
+  }
+  
+  private EvaluationReason(Kind kind, boolean inExperiment) {
+    this(kind, -1, null, null, inExperiment, null, null);
   }
   
   private EvaluationReason(ErrorKind errorKind, Exception exception) {
-    this(Kind.ERROR, -1, null, null, errorKind, exception);
+    this(Kind.ERROR, -1, null, null, NOT_IN_EXPERIMENT, errorKind, exception);
   }
   
   /**
@@ -171,6 +181,17 @@ public String getPrerequisiteKey() {
     return prerequisiteKey;
   }
 
+  /**
+   * Whether the evaluation was part of an experiment. Returns true if the evaluation 
+   * resulted in an experiment rollout *and* served one of the variations in the 
+   * experiment.  Otherwise it returns false.
+   * 
+   * @return whether the evaluation is in an experiment 
+   */
+  public boolean isInExperiment() {
+    return inExperiment;
+  }
+
   /**
    * An enumeration value indicating the general category of error, if the
    * {@code kind} is {@link Kind#PREREQUISITE_FAILED}. Otherwise {@code null}.
@@ -206,7 +227,9 @@ public Exception getException() {
   public String toString() {
     switch (kind) {
     case RULE_MATCH:
-      return kind + "(" + ruleIndex + (ruleId == null ? "" : ("," + ruleId)) + ")";
+      return kind + "(" + ruleIndex + (ruleId == null ? "" : ("," + ruleId)) + ",inExperiment: " + inExperiment + ")";
+    case FALLTHROUGH:
+      return kind + "(inExperiment: " + inExperiment + ")";
     case PREREQUISITE_FAILED:
       return kind + "(" + prerequisiteKey + ")";
     case ERROR:
@@ -223,8 +246,9 @@ public boolean equals(Object other) {
     }
     if (other instanceof EvaluationReason) {
       EvaluationReason o = (EvaluationReason)other;
-      return kind == o.kind && ruleIndex == o.ruleIndex && Objects.equals(ruleId, o.ruleId)&&
-          Objects.equals(prerequisiteKey, o.prerequisiteKey) && Objects.equals(errorKind, o.errorKind) &&
+      return kind == o.kind && ruleIndex == o.ruleIndex && Objects.equals(ruleId, o.ruleId) &&
+          Objects.equals(prerequisiteKey, o.prerequisiteKey) && Objects.equals(inExperiment, o.inExperiment) &&
+          Objects.equals(errorKind, o.errorKind) &&
           Objects.equals(exception, o.exception);
     }
     return false;
@@ -232,7 +256,7 @@ public boolean equals(Object other) {
   
   @Override
   public int hashCode() {
-    return Objects.hash(kind, ruleIndex, ruleId, prerequisiteKey, errorKind, exception);
+    return Objects.hash(kind, ruleIndex, ruleId, prerequisiteKey, inExperiment, errorKind, exception);
   }
   
   /**
@@ -253,6 +277,17 @@ public static EvaluationReason fallthrough() {
     return FALLTHROUGH_INSTANCE;
   }
   
+  /**
+   * Returns an instance whose {@code kind} is {@link Kind#FALLTHROUGH} and 
+   * where the inExperiment parameter represents whether the evaluation was
+   * part of an experiment.
+   * 
+   * @return a reason object
+   */
+  public static EvaluationReason fallthrough(boolean inExperiment) {
+    return inExperiment ? FALLTHROUGH_INSTANCE_IN_EXPERIMENT : FALLTHROUGH_INSTANCE;
+  }
+  
   /**
    * Returns an instance whose {@code kind} is {@link Kind#TARGET_MATCH}.
    * 
@@ -270,7 +305,21 @@ public static EvaluationReason targetMatch() {
    * @return a reason object
    */
   public static EvaluationReason ruleMatch(int ruleIndex, String ruleId) {
-    return new EvaluationReason(Kind.RULE_MATCH, ruleIndex, ruleId, null, null, null);
+    return ruleMatch(ruleIndex, ruleId, NOT_IN_EXPERIMENT);
+  }
+  
+  /**
+   * Returns an instance whose {@code kind} is {@link Kind#RULE_MATCH} and 
+   * where the inExperiment parameter represents whether the evaluation was
+   * part of an experiment..
+   * 
+   * @param ruleIndex the rule index
+   * @param ruleId the rule identifier
+   * @param inExperiment whether it evaluates as an experiment
+   * @return a reason object
+   */
+  public static EvaluationReason ruleMatch(int ruleIndex, String ruleId, boolean inExperiment) {
+    return new EvaluationReason(Kind.RULE_MATCH, ruleIndex, ruleId, null, inExperiment, null, null);
   }
   
   /**
@@ -280,7 +329,7 @@ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId) {
    * @return a reason object
    */
   public static EvaluationReason prerequisiteFailed(String prerequisiteKey) {
-    return new EvaluationReason(Kind.PREREQUISITE_FAILED, -1, null, prerequisiteKey, null, null);
+    return new EvaluationReason(Kind.PREREQUISITE_FAILED, -1, null, prerequisiteKey, NOT_IN_EXPERIMENT, null, null);
   }
   
   /**
diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java
index 389f0d8..0d86feb 100644
--- a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java
+++ b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java
@@ -22,6 +22,7 @@ static EvaluationReason parse(JsonReader reader) throws IOException {
     int ruleIndex = -1;
     String ruleId = null;
     String prereqKey = null;
+    boolean inExperiment = false;
     EvaluationReason.ErrorKind errorKind = null;
     
     reader.beginObject();
@@ -40,6 +41,9 @@ static EvaluationReason parse(JsonReader reader) throws IOException {
       case "prerequisiteKey":
         prereqKey = reader.nextString();
         break;
+      case "inExperiment":
+        inExperiment = reader.nextBoolean();
+        break;
       case "errorKind":
         errorKind = readEnum(EvaluationReason.ErrorKind.class, reader);
         break;
@@ -56,11 +60,11 @@ static EvaluationReason parse(JsonReader reader) throws IOException {
     case OFF:
       return EvaluationReason.off();
     case FALLTHROUGH:
-      return EvaluationReason.fallthrough();
+      return EvaluationReason.fallthrough(inExperiment);
     case TARGET_MATCH:
       return EvaluationReason.targetMatch();
     case RULE_MATCH:
-      return EvaluationReason.ruleMatch(ruleIndex, ruleId);
+      return EvaluationReason.ruleMatch(ruleIndex, ruleId, inExperiment);
     case PREREQUISITE_FAILED:
       return EvaluationReason.prerequisiteFailed(prereqKey);
     case ERROR:
@@ -85,6 +89,12 @@ public void write(JsonWriter writer, EvaluationReason reason) throws IOException
         writer.name("ruleId");
         writer.value(reason.getRuleId());
       }
+      writer.name("inExperiment");
+      writer.value(reason.isInExperiment());
+      break;
+    case FALLTHROUGH:
+      writer.name("inExperiment");
+      writer.value(reason.isInExperiment());
       break;
     case PREREQUISITE_FAILED:
       writer.name("prerequisiteKey");
diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java
index 54625f8..aa18f18 100644
--- a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java
+++ b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java
@@ -70,10 +70,14 @@ public void basicProperties() {
   @Test
   public void simpleStringRepresentations() {
     assertEquals("OFF", EvaluationReason.off().toString());
-    assertEquals("FALLTHROUGH", EvaluationReason.fallthrough().toString());
+    assertEquals("FALLTHROUGH(inExperiment: false)", EvaluationReason.fallthrough().toString());
+    assertEquals("FALLTHROUGH(inExperiment: false)", EvaluationReason.fallthrough(false).toString());
+    assertEquals("FALLTHROUGH(inExperiment: true)", EvaluationReason.fallthrough(true).toString());
     assertEquals("TARGET_MATCH", EvaluationReason.targetMatch().toString());
-    assertEquals("RULE_MATCH(1)", EvaluationReason.ruleMatch(1, null).toString());
-    assertEquals("RULE_MATCH(1,id)", EvaluationReason.ruleMatch(1, "id").toString());
+    assertEquals("RULE_MATCH(1,inExperiment: false)", EvaluationReason.ruleMatch(1, null).toString());
+    assertEquals("RULE_MATCH(1,id,inExperiment: false)", EvaluationReason.ruleMatch(1, "id").toString());
+    assertEquals("RULE_MATCH(1,id,inExperiment: false)", EvaluationReason.ruleMatch(1, "id", false).toString());
+    assertEquals("RULE_MATCH(1,id,inExperiment: true)", EvaluationReason.ruleMatch(1, "id", true).toString());
     assertEquals("PREREQUISITE_FAILED(key)", EvaluationReason.prerequisiteFailed("key").toString());
     assertEquals("ERROR(FLAG_NOT_FOUND)", EvaluationReason.error(FLAG_NOT_FOUND).toString());
     assertEquals("ERROR(EXCEPTION)", EvaluationReason.exception(null).toString());
diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java
index 1ae82e0..b0c6e5f 100644
--- a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java
+++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java
@@ -14,12 +14,22 @@ public class EvaluationReasonJsonSerializationTest extends BaseTest {
   @Test
   public void reasonJsonSerializations() throws Exception {
     verifySerializeAndDeserialize(EvaluationReason.off(), "{\"kind\":\"OFF\"}");
-    verifySerializeAndDeserialize(EvaluationReason.fallthrough(), "{\"kind\":\"FALLTHROUGH\"}");
+    verifySerializeAndDeserialize(EvaluationReason.fallthrough(), "{\"kind\":\"FALLTHROUGH\",\"inExperiment\":false}");
+    verifySerializeAndDeserialize(EvaluationReason.fallthrough(false), "{\"kind\":\"FALLTHROUGH\",\"inExperiment\":false}");
+    verifySerializeAndDeserialize(EvaluationReason.fallthrough(true), "{\"kind\":\"FALLTHROUGH\",\"inExperiment\":true}");
     verifySerializeAndDeserialize(EvaluationReason.targetMatch(), "{\"kind\":\"TARGET_MATCH\"}");
     verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, "id"),
-        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\"}");
+        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\",\"inExperiment\":false}");
+    verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, "id", false),
+        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\",\"inExperiment\":false}");
+    verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, "id", true),
+        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\",\"inExperiment\":true}");
     verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, null),
-        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1}");
+        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"inExperiment\":false}");
+    verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, null, false),
+        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"inExperiment\":false}");
+    verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, null, true),
+        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"inExperiment\":true}");
     verifySerializeAndDeserialize(EvaluationReason.prerequisiteFailed("key"),
         "{\"kind\":\"PREREQUISITE_FAILED\",\"prerequisiteKey\":\"key\"}");
     verifySerializeAndDeserialize(EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND),

From 0ea70c9483386bd7f2992319ad601b1696c770df Mon Sep 17 00:00:00 2001
From: "Robert J. Neal" 
Date: Fri, 2 Apr 2021 18:11:32 -0700
Subject: [PATCH 31/91] fix javadoc

---
 src/main/java/com/launchdarkly/sdk/EvaluationReason.java | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
index dab4f94..c79d932 100644
--- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
+++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
@@ -282,6 +282,7 @@ public static EvaluationReason fallthrough() {
    * where the inExperiment parameter represents whether the evaluation was
    * part of an experiment.
    * 
+   * @param inExperiment whether it evaluates as an experiment
    * @return a reason object
    */
   public static EvaluationReason fallthrough(boolean inExperiment) {

From 532fa70f77accf25dd3e1840d9fb681db935fb01 Mon Sep 17 00:00:00 2001
From: "Robert J. Neal" 
Date: Tue, 6 Apr 2021 08:03:57 -0700
Subject: [PATCH 32/91] Update
 src/main/java/com/launchdarkly/sdk/EvaluationReason.java

Co-authored-by: Sam Stokes 
---
 src/main/java/com/launchdarkly/sdk/EvaluationReason.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
index c79d932..689cf4e 100644
--- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
+++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
@@ -312,7 +312,7 @@ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId) {
   /**
    * Returns an instance whose {@code kind} is {@link Kind#RULE_MATCH} and 
    * where the inExperiment parameter represents whether the evaluation was
-   * part of an experiment..
+   * part of an experiment.
    * 
    * @param ruleIndex the rule index
    * @param ruleId the rule identifier

From 7526a58bf0e230609a498f2c41b51e34e33e9d3d Mon Sep 17 00:00:00 2001
From: "Robert J. Neal" 
Date: Tue, 6 Apr 2021 08:04:12 -0700
Subject: [PATCH 33/91] Update
 src/main/java/com/launchdarkly/sdk/EvaluationReason.java

Co-authored-by: Sam Stokes 
---
 src/main/java/com/launchdarkly/sdk/EvaluationReason.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
index 689cf4e..7096224 100644
--- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
+++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
@@ -316,7 +316,7 @@ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId) {
    * 
    * @param ruleIndex the rule index
    * @param ruleId the rule identifier
-   * @param inExperiment whether it evaluates as an experiment
+   * @param inExperiment whether the evaluation was part of an experiment
    * @return a reason object
    */
   public static EvaluationReason ruleMatch(int ruleIndex, String ruleId, boolean inExperiment) {

From e3c06ecbb7540875597b636de4de26b89d70c1d6 Mon Sep 17 00:00:00 2001
From: "Robert J. Neal" 
Date: Tue, 6 Apr 2021 08:04:21 -0700
Subject: [PATCH 34/91] Update
 src/main/java/com/launchdarkly/sdk/EvaluationReason.java

Co-authored-by: Sam Stokes 
---
 src/main/java/com/launchdarkly/sdk/EvaluationReason.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
index 7096224..70b9273 100644
--- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
+++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
@@ -282,7 +282,7 @@ public static EvaluationReason fallthrough() {
    * where the inExperiment parameter represents whether the evaluation was
    * part of an experiment.
    * 
-   * @param inExperiment whether it evaluates as an experiment
+   * @param inExperiment whether the evaluation was part of an experiment
    * @return a reason object
    */
   public static EvaluationReason fallthrough(boolean inExperiment) {

From 0b6aef377968c826c7c0b12666590540aafe45ff Mon Sep 17 00:00:00 2001
From: "Robert J. Neal" 
Date: Tue, 6 Apr 2021 08:05:17 -0700
Subject: [PATCH 35/91] Update
 src/main/java/com/launchdarkly/sdk/EvaluationReason.java

Co-authored-by: Sam Stokes 
---
 src/main/java/com/launchdarkly/sdk/EvaluationReason.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
index 70b9273..08768fa 100644
--- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
+++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
@@ -186,7 +186,7 @@ public String getPrerequisiteKey() {
    * resulted in an experiment rollout *and* served one of the variations in the 
    * experiment.  Otherwise it returns false.
    * 
-   * @return whether the evaluation is in an experiment 
+   * @return whether the evaluation was part of an experiment
    */
   public boolean isInExperiment() {
     return inExperiment;

From 6e1b358f4d1efc4113c065599ce2e53a47a15f16 Mon Sep 17 00:00:00 2001
From: "Robert J. Neal" 
Date: Tue, 6 Apr 2021 08:20:28 -0700
Subject: [PATCH 36/91] respond to review comments

---
 .../com/launchdarkly/sdk/EvaluationReason.java | 15 ++++++++-------
 .../sdk/EvaluationReasonTypeAdapter.java       |  8 ++++++--
 .../EvaluationReasonJsonSerializationTest.java | 18 ++++++++++++------
 3 files changed, 26 insertions(+), 15 deletions(-)

diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
index 08768fa..47bfbac 100644
--- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
+++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java
@@ -227,9 +227,7 @@ public Exception getException() {
   public String toString() {
     switch (kind) {
     case RULE_MATCH:
-      return kind + "(" + ruleIndex + (ruleId == null ? "" : ("," + ruleId)) + ",inExperiment: " + inExperiment + ")";
-    case FALLTHROUGH:
-      return kind + "(inExperiment: " + inExperiment + ")";
+      return kind + "(" + ruleIndex + (ruleId == null ? "" : ("," + ruleId)) + ")";
     case PREREQUISITE_FAILED:
       return kind + "(" + prerequisiteKey + ")";
     case ERROR:
@@ -246,10 +244,13 @@ public boolean equals(Object other) {
     }
     if (other instanceof EvaluationReason) {
       EvaluationReason o = (EvaluationReason)other;
-      return kind == o.kind && ruleIndex == o.ruleIndex && Objects.equals(ruleId, o.ruleId) &&
-          Objects.equals(prerequisiteKey, o.prerequisiteKey) && Objects.equals(inExperiment, o.inExperiment) &&
-          Objects.equals(errorKind, o.errorKind) &&
-          Objects.equals(exception, o.exception);
+      return kind == o.kind && 
+        ruleIndex == o.ruleIndex && 
+        Objects.equals(ruleId, o.ruleId) &&
+        Objects.equals(prerequisiteKey, o.prerequisiteKey) && 
+        inExperiment == o.inExperiment &&
+        Objects.equals(errorKind, o.errorKind) &&
+        Objects.equals(exception, o.exception);
     }
     return false;
   }
diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java
index 0d86feb..122f72f 100644
--- a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java
+++ b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java
@@ -89,12 +89,16 @@ public void write(JsonWriter writer, EvaluationReason reason) throws IOException
         writer.name("ruleId");
         writer.value(reason.getRuleId());
       }
-      writer.name("inExperiment");
-      writer.value(reason.isInExperiment());
+      if (reason.isInExperiment()) {
+        writer.name("inExperiment");
+        writer.value(reason.isInExperiment());
+      }
       break;
     case FALLTHROUGH:
+    if (reason.isInExperiment()) {
       writer.name("inExperiment");
       writer.value(reason.isInExperiment());
+    }
       break;
     case PREREQUISITE_FAILED:
       writer.name("prerequisiteKey");
diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java
index b0c6e5f..0af6d32 100644
--- a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java
+++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java
@@ -6,6 +6,7 @@
 import org.junit.Test;
 
 import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson;
+import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize;
 import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize;
 import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize;
 
@@ -14,20 +15,20 @@ public class EvaluationReasonJsonSerializationTest extends BaseTest {
   @Test
   public void reasonJsonSerializations() throws Exception {
     verifySerializeAndDeserialize(EvaluationReason.off(), "{\"kind\":\"OFF\"}");
-    verifySerializeAndDeserialize(EvaluationReason.fallthrough(), "{\"kind\":\"FALLTHROUGH\",\"inExperiment\":false}");
-    verifySerializeAndDeserialize(EvaluationReason.fallthrough(false), "{\"kind\":\"FALLTHROUGH\",\"inExperiment\":false}");
+    verifySerializeAndDeserialize(EvaluationReason.fallthrough(), "{\"kind\":\"FALLTHROUGH\"}");
+    verifySerializeAndDeserialize(EvaluationReason.fallthrough(false), "{\"kind\":\"FALLTHROUGH\"}");
     verifySerializeAndDeserialize(EvaluationReason.fallthrough(true), "{\"kind\":\"FALLTHROUGH\",\"inExperiment\":true}");
     verifySerializeAndDeserialize(EvaluationReason.targetMatch(), "{\"kind\":\"TARGET_MATCH\"}");
     verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, "id"),
-        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\",\"inExperiment\":false}");
+        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\"}");
     verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, "id", false),
-        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\",\"inExperiment\":false}");
+        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\"}");
     verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, "id", true),
         "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\",\"inExperiment\":true}");
     verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, null),
-        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"inExperiment\":false}");
+        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1}");
     verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, null, false),
-        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"inExperiment\":false}");
+        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1}");
     verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, null, true),
         "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"inExperiment\":true}");
     verifySerializeAndDeserialize(EvaluationReason.prerequisiteFailed("key"),
@@ -35,6 +36,11 @@ public void reasonJsonSerializations() throws Exception {
     verifySerializeAndDeserialize(EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND),
         "{\"kind\":\"ERROR\",\"errorKind\":\"FLAG_NOT_FOUND\"}");
 
+    // properties with defaults can be included
+    verifyDeserialize(EvaluationReason.fallthrough(false), "{\"kind\":\"FALLTHROUGH\",\"inExperiment\":false}");
+    verifyDeserialize(EvaluationReason.ruleMatch(1, "id", false),
+        "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\",\"inExperiment\":false}");
+
     // unknown properties are ignored
     JsonTestHelpers.verifyDeserialize(EvaluationReason.off(), "{\"kind\":\"OFF\",\"other\":true}");
     

From 76533cedd40a4dd8a73d2c5c6937ad434e1a3e8f Mon Sep 17 00:00:00 2001
From: "Robert J. Neal" 
Date: Tue, 6 Apr 2021 08:24:49 -0700
Subject: [PATCH 37/91] fix test

---
 .../com/launchdarkly/sdk/EvaluationReasonTest.java    | 11 ++++-------
 1 file changed, 4 insertions(+), 7 deletions(-)

diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java
index aa18f18..5311f9f 100644
--- a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java
+++ b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java
@@ -70,14 +70,11 @@ public void basicProperties() {
   @Test
   public void simpleStringRepresentations() {
     assertEquals("OFF", EvaluationReason.off().toString());
-    assertEquals("FALLTHROUGH(inExperiment: false)", EvaluationReason.fallthrough().toString());
-    assertEquals("FALLTHROUGH(inExperiment: false)", EvaluationReason.fallthrough(false).toString());
-    assertEquals("FALLTHROUGH(inExperiment: true)", EvaluationReason.fallthrough(true).toString());
     assertEquals("TARGET_MATCH", EvaluationReason.targetMatch().toString());
-    assertEquals("RULE_MATCH(1,inExperiment: false)", EvaluationReason.ruleMatch(1, null).toString());
-    assertEquals("RULE_MATCH(1,id,inExperiment: false)", EvaluationReason.ruleMatch(1, "id").toString());
-    assertEquals("RULE_MATCH(1,id,inExperiment: false)", EvaluationReason.ruleMatch(1, "id", false).toString());
-    assertEquals("RULE_MATCH(1,id,inExperiment: true)", EvaluationReason.ruleMatch(1, "id", true).toString());
+    assertEquals("RULE_MATCH(1)", EvaluationReason.ruleMatch(1, null).toString());
+    assertEquals("RULE_MATCH(1,id)", EvaluationReason.ruleMatch(1, "id").toString());
+    assertEquals("RULE_MATCH(1,id)", EvaluationReason.ruleMatch(1, "id", false).toString());
+    assertEquals("RULE_MATCH(1,id)", EvaluationReason.ruleMatch(1, "id", true).toString());
     assertEquals("PREREQUISITE_FAILED(key)", EvaluationReason.prerequisiteFailed("key").toString());
     assertEquals("ERROR(FLAG_NOT_FOUND)", EvaluationReason.error(FLAG_NOT_FOUND).toString());
     assertEquals("ERROR(EXCEPTION)", EvaluationReason.exception(null).toString());

From b468c4ea64e374b22a0522808d12063381b58500 Mon Sep 17 00:00:00 2001
From: Eli Bishop 
Date: Thu, 8 Apr 2021 11:53:32 -0700
Subject: [PATCH 38/91] javadoc fix

---
 src/main/java/com/launchdarkly/sdk/json/LDGson.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/main/java/com/launchdarkly/sdk/json/LDGson.java b/src/main/java/com/launchdarkly/sdk/json/LDGson.java
index a762b16..cce6a3a 100644
--- a/src/main/java/com/launchdarkly/sdk/json/LDGson.java
+++ b/src/main/java/com/launchdarkly/sdk/json/LDGson.java
@@ -124,7 +124,7 @@ public static JsonElement valueToJsonElement(LDValue value) {
   }
   
   /**
-   * Convenience method for converting a map of {@link LDValue} values to a map of Gson {@codeJsonElement}s.
+   * Convenience method for converting a map of {@link LDValue} values to a map of Gson {@code JsonElement}s.
    * 
    * @param  type of the map's keys
    * @param valueMap a map containing {@link LDValue} values

From a5abd0891d151a2c268acbb8a2335a554b89ea8e Mon Sep 17 00:00:00 2001
From: Eli Bishop 
Date: Thu, 8 Apr 2021 12:01:56 -0700
Subject: [PATCH 39/91] add validation of javadoc build in CI

---
 .circleci/config.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.circleci/config.yml b/.circleci/config.yml
index bcae287..7961a08 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -44,6 +44,7 @@ jobs:
       - run: java -version
       - run: ./gradlew dependencies
       - run: ./gradlew jar
+      - run: ./gradlew javadoc
       - run: ./gradlew checkstyleMain
       - persist_to_workspace:
           root: build

From 1e1168410e157aee66f67a18c165d108dcea5aca Mon Sep 17 00:00:00 2001
From: Eli Bishop 
Date: Thu, 8 Apr 2021 12:02:37 -0700
Subject: [PATCH 40/91] add another Eclipse metadata exclusion to .gitignore

---
 .gitignore | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index b8a8f70..9cbae19 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,8 @@
 # Eclipse project files
 .classpath
 .project
- 
+.settings
+
 # Intellij project files
 *.iml
 *.ipr

From 3a8aa98245503f25673db958db8e29c640bb0f7d Mon Sep 17 00:00:00 2001
From: Eli Bishop 
Date: Wed, 14 Apr 2021 14:34:17 -0700
Subject: [PATCH 41/91] partially revert ch103941 fix that doesn't work in the
 Java SDK

---
 build.gradle                                  |   5 +-
 .../EvaluationDetailTypeAdapterFactory.java   |   1 +
 .../sdk/EvaluationReasonTypeAdapter.java      |   6 +-
 .../java/com/launchdarkly/sdk/Helpers.java    |   9 -
 .../java/com/launchdarkly/sdk/LDValue.java    |   2 +-
 .../launchdarkly/sdk/LDValueTypeAdapter.java  |   4 +-
 .../sdk/json/GsonReaderAdapter.java           |  91 ---------
 .../sdk/json/GsonWriterAdapter.java           | 140 --------------
 .../sdk/json/JsonSerialization.java           |  10 +
 .../com/launchdarkly/sdk/json/LDGson.java     | 177 ++----------------
 .../com/launchdarkly/sdk/LDValueTest.java     |  27 ---
 .../com/launchdarkly/sdk/json/LDGsonTest.java | 112 ++---------
 .../sdk/json/LDUserJsonSerializationTest.java |   3 -
 .../json/LDValueJsonSerializationTest.java    |   5 +-
 14 files changed, 57 insertions(+), 535 deletions(-)
 delete mode 100644 src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java
 delete mode 100644 src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java

diff --git a/build.gradle b/build.gradle
index fffba90..73ee6ad 100644
--- a/build.gradle
+++ b/build.gradle
@@ -89,9 +89,8 @@ jacocoTestCoverageVerification {
 			"EvaluationReasonTypeAdapter.parse(com.google.gson.stream.JsonReader)": 1,
 			"EvaluationDetailTypeAdapterFactory.EvaluationDetailTypeAdapter.read(com.google.gson.stream.JsonReader)": 1,
 			"LDValue.equals(java.lang.Object)": 1,
-			"LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)": 1,
-			"json.JsonSerialization.getDeserializableClasses()": -1,
-			"json.LDGson.LDTypeAdapter.write(com.google.gson.stream.JsonWriter, java.lang.Object)": 1
+			"LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)": 3,
+			"json.JsonSerialization.getDeserializableClasses()": -1
 		]
 		
 		knownMissedLinesForMethods.each { partialSignature, maxMissedLines ->
diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java
index a982856..b6d66c7 100644
--- a/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java
+++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java
@@ -87,5 +87,6 @@ public EvaluationDetail read(JsonReader in) throws IOException {
 
       return EvaluationDetail.fromValue(value, variation, reason);
     }
+    
   }
 }
diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java
index 389f0d8..a505eb9 100644
--- a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java
+++ b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java
@@ -8,7 +8,7 @@
 
 import java.io.IOException;
 
-import static com.launchdarkly.sdk.Helpers.readEnum;
+import static com.launchdarkly.sdk.Helpers.readNonNullableString;
 import static com.launchdarkly.sdk.Helpers.readNullableString;
 
 final class EvaluationReasonTypeAdapter extends TypeAdapter {
@@ -29,7 +29,7 @@ static EvaluationReason parse(JsonReader reader) throws IOException {
       String key = reader.nextName();
       switch (key) { // COVERAGE: may have spurious "branches missed" warning, see https://stackoverflow.com/questions/28013717/eclemma-branch-coverage-for-switch-7-of-19-missed
       case "kind":
-        kind = readEnum(EvaluationReason.Kind.class, reader);
+        kind = Enum.valueOf(EvaluationReason.Kind.class, readNonNullableString(reader));
         break;
       case "ruleIndex":
         ruleIndex = reader.nextInt();
@@ -41,7 +41,7 @@ static EvaluationReason parse(JsonReader reader) throws IOException {
         prereqKey = reader.nextString();
         break;
       case "errorKind":
-        errorKind = readEnum(EvaluationReason.ErrorKind.class, reader);
+        errorKind = Enum.valueOf(EvaluationReason.ErrorKind.class, readNonNullableString(reader));
         break;
       default:
         reader.skipValue(); // ignore any unexpected property
diff --git a/src/main/java/com/launchdarkly/sdk/Helpers.java b/src/main/java/com/launchdarkly/sdk/Helpers.java
index aafe4f7..f602d0b 100644
--- a/src/main/java/com/launchdarkly/sdk/Helpers.java
+++ b/src/main/java/com/launchdarkly/sdk/Helpers.java
@@ -56,13 +56,4 @@ static String readNonNullableString(JsonReader reader) throws IOException {
       throw new JsonParseException("expected string value");
     }
   }
-  
-  static > T readEnum(Class enumClass, JsonReader reader) throws IOException {
-    String s = readNonNullableString(reader);
-    try {
-      return Enum.valueOf(enumClass, s);
-    } catch (IllegalArgumentException e) {
-      throw new JsonParseException(String.format("unsupported value \"{}\" for {}", s, enumClass));
-    }
-  }
 }
diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java
index 956dc7f..63440ea 100644
--- a/src/main/java/com/launchdarkly/sdk/LDValue.java
+++ b/src/main/java/com/launchdarkly/sdk/LDValue.java
@@ -192,7 +192,7 @@ public static ObjectBuilder buildObject() {
    */
   public static LDValue parse(String json) {
     try {
-      return LDValue.normalize(JsonSerialization.deserialize(json, LDValue.class));
+      return JsonSerialization.deserialize(json, LDValue.class);
     } catch (SerializationException e) {
       throw new RuntimeException(e);
     }
diff --git a/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java
index 88eb956..bd5f419 100644
--- a/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java
+++ b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java
@@ -35,6 +35,7 @@ public LDValue read(JsonReader reader) throws IOException {
     case BOOLEAN:
       return LDValue.of(reader.nextBoolean());
     case NULL:
+      // COVERAGE: this branch won't be reached because Gson does not call the TypeAdapter when there's a null.
       reader.nextNull();
       return LDValue.ofNull();
     case NUMBER:
@@ -42,8 +43,7 @@ public LDValue read(JsonReader reader) throws IOException {
     case STRING:
       return LDValue.of(reader.nextString());
     default:
-      // COVERAGE: this branch won't be reached because Gson does not call the TypeAdapter if the next token
-      // isn't any of the above.
+      // COVERAGE: this branch won't be reached because Gson does not call the TypeAdapter if the next token isn't well-formed JSON.
       return null;
     }
   }
diff --git a/src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java b/src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java
deleted file mode 100644
index e96fc28..0000000
--- a/src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java
+++ /dev/null
@@ -1,91 +0,0 @@
-package com.launchdarkly.sdk.json;
-
-import com.google.gson.stream.JsonReader;
-import com.google.gson.stream.JsonToken;
-
-import java.io.CharArrayReader;
-import java.io.IOException;
-import java.io.Reader;
-
-// This type is a bridge between the Gson classes on the application classpath and the Gson classes
-// that are used internally.
-//
-// In some SDK distributions, there is an internal set of Gson classes that have modified (shaded)
-// class names, to ensure that the SDK can use its own version of Gson without conflicting with the
-// application. If so, all references to Gson classes in the SDK code will be transformed to the
-// shaded class names *except* within the LDGson class. This means that our Gson TypeAdapters can't
-// interact directly with a JsonReader or JsonWriter that is provided by the application.
-//
-// GsonReaderAdapter and GsonWriterAdapter, since they are declared outside of the LDGson class,
-// *will* have all Gson types in their class/method signatures shaded if we are using shading.
-// Therefore, they can be used with our internal Gson logic. But the actual implementation of their
-// methods is done by a subclass that is an inner class of LDGson-- so, that class can interact
-// with unshaded Gson classes provided by the application.
-//
-// So, if all com.google.gson classes are being shaded to com.launchdarkly.shaded.com.google.gson,
-// then the base class of GsonReaderAdapter is com.launchdarkly.shaded.com.google.gson.JsonReader;
-// the class LDGson.DelegatingJsonReaderAdapter is a GsonReaderAdapter, so it is also a
-// com.launchdarkly.shaded.com.google.gson.JsonReader; but references to JsonReader within the
-// implementation of LDGson.DelegatingJsonReaderAdapter are to com.google.json.JsonReader.
-//
-// In SDK distributions that do not use shading, these types are not really necessary, but their
-// overhead is minimal so we use them in all cases.
-abstract class GsonReaderAdapter extends JsonReader {
-  private static final JsonToken[] TOKEN_VALUES = JsonToken.values();
-  
-  GsonReaderAdapter() {
-    super(makeStubReader());
-  }
-
-  private static final Reader makeStubReader() {
-    // The JsonReader constructor requires a non-null Reader, but we won't actually be using it.
-    // Unfortunately Java 7 doesn't implement a completely no-op Reader. 
-    return new CharArrayReader(new char[0]);
-  }
-  
-  @Override
-  abstract public void beginArray() throws IOException;
-  
-  @Override
-  abstract public void beginObject() throws IOException;
-  
-  @Override
-  abstract public void endArray() throws IOException;
-  
-  @Override
-  abstract public void endObject() throws IOException;
-  
-  @Override
-  abstract public boolean hasNext() throws IOException;
-  
-  @Override
-  abstract public boolean nextBoolean() throws IOException;
-  
-  @Override
-  abstract public double nextDouble() throws IOException;
-  
-  @Override
-  abstract public int nextInt() throws IOException;
-  
-  @Override
-  abstract public long nextLong() throws IOException;
-  
-  @Override
-  abstract public String nextName() throws IOException;
-  
-  @Override
-  abstract public void nextNull() throws IOException;
-  
-  @Override
-  abstract public String nextString() throws IOException;
-  
-  @Override
-  public JsonToken peek() throws IOException {
-    return TOKEN_VALUES[peekInternal()];
-  }
-  
-  @Override
-  abstract public void skipValue() throws IOException;
-  
-  abstract protected int peekInternal() throws IOException; // should return the ordinal of the JsonToken enum
-}
diff --git a/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java b/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java
deleted file mode 100644
index 29e4236..0000000
--- a/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java
+++ /dev/null
@@ -1,140 +0,0 @@
-package com.launchdarkly.sdk.json;
-
-import com.google.gson.stream.JsonWriter;
-
-import java.io.CharArrayWriter;
-import java.io.IOException;
-import java.io.Writer;
-
-// This type is a bridge between the Gson classes on the application classpath and the Gson classes
-// that are used internally.
-//
-// In some SDK distributions, there is an internal set of Gson classes that have modified (shaded)
-// class names, to ensure that the SDK can use its own version of Gson without conflicting with the
-// application. If so, all references to Gson classes in the SDK code will be transformed to the
-// shaded class names *except* within the LDGson class. This means that our Gson TypeAdapters can't
-// interact directly with a JsonReader or JsonWriter that is provided by the application.
-//
-// GsonReaderAdapter and GsonWriterAdapter, since they are declared outside of the LDGson class,
-// *will* have all Gson types in their class/method signatures shaded if we are using shading.
-// Therefore, they can be used with our internal Gson logic. But the actual implementation of their
-// methods is done by a subclass that is an inner class of LDGson-- so, that class can interact
-// with unshaded Gson classes provided by the application.
-//
-// So, if all com.google.gson classes are being shaded to com.launchdarkly.shaded.com.google.gson,
-// then the base class of GsonWriterAdapter is com.launchdarkly.shaded.com.google.gson.JsonWriter;
-// the class LDGson.DelegatingJsonWriterAdapter is a GsonWriterAdapter, so it is also a
-// com.launchdarkly.shaded.com.google.gson.JsonWriter; but references to JsonWriter within the
-// implementation of LDGson.DelegatingJsonWriterAdapter are to com.google.json.JsonWriter.
-//
-// In SDK distributions that do not use shading, these types are not really necessary, but their
-// overhead is minimal so we use them in all cases.
-abstract class GsonWriterAdapter extends JsonWriter {
-  GsonWriterAdapter() {
-    super(makeStubWriter());
-  }
-  
-  private static final Writer makeStubWriter() {
-    // The JsonWriter constructor requires a non-null Writer, but we won't actually be using it.
-    // Unfortunately Java 7 doesn't implement a completely no-op Writer. 
-    return new CharArrayWriter(0);
-  }
-  
-  @Override
-  public JsonWriter beginArray() throws IOException {
-    beginArrayInternal();
-    return this;
-  }
-  
-  @Override
-  public JsonWriter beginObject() throws IOException {
-    beginObjectInternal();
-    return this;
-  }
-  
-  @Override
-  public JsonWriter endArray() throws IOException {
-    endArrayInternal();
-    return this;
-  }
-  
-  @Override
-  public JsonWriter endObject() throws IOException {
-    endObjectInternal();
-    return this;
-  }
-  
-  @Override
-  public JsonWriter jsonValue(String value) throws IOException {
-    jsonValueInternal(value);
-    return this;
-  }
-  
-  @Override
-  public JsonWriter name(String name) throws IOException {
-    nameInternal(name);
-    return this;
-  }
-  
-  @Override
-  public JsonWriter nullValue() throws IOException {
-    valueInternalNull();
-    return this;
-  }
-
-  @Override
-  public JsonWriter value(boolean value) throws IOException {
-    valueInternalBool(value);
-    return this;
-  }
-  
-  @Override
-  public JsonWriter value(Boolean value) throws IOException {
-    if (value == null) {
-      valueInternalNull();
-    } else {
-      valueInternalBool(value.booleanValue());
-    }
-    return this;
-  }
-  
-  @Override
-  public JsonWriter value(double value) throws IOException {
-    valueInternalDouble(value);
-    return this;
-  }
-
-  @Override
-  public JsonWriter value(long value) throws IOException {
-    valueInternalLong(value);
-    return this;
-  }
-
-  @Override
-  public JsonWriter value(Number value) throws IOException {
-    valueInternalNumber(value);
-    return this;
-  }
-  
-  @Override
-  public JsonWriter value(String value) throws IOException {
-    valueInternalString(value);
-    return this;
-  }
-  
-  @Override
-  public void close() throws IOException {}
-  
-  protected abstract void beginArrayInternal() throws IOException;
-  protected abstract void beginObjectInternal() throws IOException;
-  protected abstract void endArrayInternal() throws IOException;
-  protected abstract void endObjectInternal() throws IOException;
-  protected abstract void jsonValueInternal(String value) throws IOException;
-  protected abstract void nameInternal(String name) throws IOException;
-  protected abstract void valueInternalNull() throws IOException;
-  protected abstract void valueInternalBool(boolean value) throws IOException;
-  protected abstract void valueInternalDouble(double value) throws IOException;
-  protected abstract void valueInternalLong(long value) throws IOException;
-  protected abstract void valueInternalNumber(Number value) throws IOException;
-  protected abstract void valueInternalString(String value) throws IOException;
-}
diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java
index bdab08b..63b8169 100644
--- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java
+++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java
@@ -8,6 +8,7 @@
 import com.launchdarkly.sdk.UserAttribute;
 
 import java.lang.reflect.Method;
+import java.lang.reflect.Type;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -85,6 +86,15 @@ static  T deserializeInternal(String json, Class objectClass) throws Seria
     }
   }
 
+  // Used internally from LDGson
+  static  T deserializeInternalGson(String json, Type objectType) throws SerializationException {
+    try {
+      return gson.fromJson(json, objectType);
+    } catch (Exception e) {
+      throw new SerializationException(e);
+    }
+  }
+  
   /**
    * Internal method to return all of the classes that we should have a custom deserializer for.
    * 

diff --git a/src/main/java/com/launchdarkly/sdk/json/LDGson.java b/src/main/java/com/launchdarkly/sdk/json/LDGson.java index cce6a3a..e041c05 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDGson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDGson.java @@ -5,6 +5,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonNull; import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; import com.google.gson.JsonPrimitive; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; @@ -145,181 +146,41 @@ private static class LDTypeAdapterFactory implements TypeAdapterFactory { @Override public TypeAdapter create(Gson gson, TypeToken type) { if (JsonSerializable.class.isAssignableFrom(type.getRawType())) { - return new LDTypeAdapter(type.getType()); + return new LDTypeAdapter(gson, type.getType()); } return null; } } private static class LDTypeAdapter extends TypeAdapter { + private final Gson gson; private final Type objectType; - LDTypeAdapter(Type objectType) { + LDTypeAdapter(Gson gson, Type objectType) { + this.gson = gson; this.objectType = objectType; } @Override public void write(JsonWriter out, T value) throws IOException { - if (value == null) { - // COVERAGE: we don't expect this to ever happen, since Gson normally doesn't bother to call - // the type adapter for any null value; it's just a sanity check. - out.nullValue(); - } else { - JsonSerialization.gson.toJson(value, value.getClass(), new DelegatingJsonWriterAdapter(out)); - } + String json = JsonSerialization.serializeInternal(value); + out.jsonValue(json); } @Override public T read(JsonReader in) throws IOException { - return JsonSerialization.gson.fromJson(new DelegatingJsonReaderAdapter(in), objectType); - } - } - - // See comments on GsonReaderAdapter for the reason this type exists. - static class DelegatingJsonReaderAdapter extends GsonReaderAdapter { - private final JsonReader reader; - - DelegatingJsonReaderAdapter(JsonReader reader) { - this.reader = reader; - } - - @Override - public void beginArray() throws IOException { - reader.beginArray(); - } - - @Override - public void beginObject() throws IOException { - reader.beginObject(); - } - - @Override - public void endArray() throws IOException { - reader.endArray(); - } - - @Override - public void endObject() throws IOException { - reader.endObject(); - } - - @Override - public boolean hasNext() throws IOException { - return reader.hasNext(); - } - - @Override - public boolean nextBoolean() throws IOException { - return reader.nextBoolean(); - } - - @Override - public double nextDouble() throws IOException { - return reader.nextDouble(); - } - - @Override - public int nextInt() throws IOException { - return reader.nextInt(); - } - - @Override - public long nextLong() throws IOException { - return reader.nextLong(); - } - - @Override - public String nextName() throws IOException { - return reader.nextName(); - } - - @Override - public void nextNull() throws IOException { - reader.nextNull(); - } - - @Override - public String nextString() throws IOException { - return reader.nextString(); - } - - @Override - public void skipValue() throws IOException { - reader.skipValue(); - } - - @Override - protected int peekInternal() throws IOException { - return reader.peek().ordinal(); - } - } - - // See comments on GsonWriterAdapter for the reason this type exists. - static class DelegatingJsonWriterAdapter extends GsonWriterAdapter { - private final JsonWriter writer; - - DelegatingJsonWriterAdapter(JsonWriter writer) { - this.writer = writer; - } - - @Override - protected void beginArrayInternal() throws IOException { - writer.beginArray(); - } - - @Override - protected void beginObjectInternal() throws IOException { - writer.beginObject(); - } - - @Override - protected void endArrayInternal() throws IOException { - writer.endArray(); - } - - @Override - protected void endObjectInternal() throws IOException { - writer.endObject(); - } - - @Override - protected void jsonValueInternal(String value) throws IOException { - writer.jsonValue(value); - } - - @Override - protected void nameInternal(String name) throws IOException { - writer.name(name); - } - - @Override - protected void valueInternalNull() throws IOException { - writer.nullValue(); - } - - @Override - protected void valueInternalBool(boolean value) throws IOException { - writer.value(value); - } - - @Override - protected void valueInternalDouble(double value) throws IOException { - writer.value(value); - } - - @Override - protected void valueInternalLong(long value) throws IOException { - writer.value(value); - } - - @Override - protected void valueInternalNumber(Number value) throws IOException { - writer.value(value); - } - - @Override - protected void valueInternalString(String value) throws IOException { - writer.value(value); + // This implementation is inefficient because we can't assume our internal Gson instance can + // use this JsonReader directly; instead we have to read the next JSON value, convert it to a + // string, and then ask our JsonSerialization to parse it back from a string. + JsonElement jsonTree = gson.fromJson(in, JsonElement.class); + String jsonString = gson.toJson(jsonTree); + try { + // Calling the Gson overload that takes a Type rather than a Class (even though a Class *is* a + // Type) allows it to take generic type parameters into account for EvaluationDetail. + return JsonSerialization.deserializeInternalGson(jsonString, objectType); + } catch (SerializationException e) { + throw new JsonParseException(e.getCause()); + } } } } diff --git a/src/test/java/com/launchdarkly/sdk/LDValueTest.java b/src/test/java/com/launchdarkly/sdk/LDValueTest.java index 39bdac9..af49bd9 100644 --- a/src/test/java/com/launchdarkly/sdk/LDValueTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueTest.java @@ -1,12 +1,9 @@ package com.launchdarkly.sdk; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.MalformedJsonException; import com.launchdarkly.sdk.json.SerializationException; import org.junit.Test; -import java.io.StringReader; import java.util.List; import static java.util.Arrays.asList; @@ -261,28 +258,4 @@ public void parseThrowsRuntimeExceptionForMalformedJson() { assertThat(e.getCause(), instanceOf(SerializationException.class)); } } - - @Test - public void testLowLevelTypeAdapter() throws Exception { - // This test ensures full test coverage of LDValueTypeAdapter code paths that might not - // be exercised indirectly by other tests. - verifyTypeAdapterRead("null", LDValue.ofNull()); - verifyTypeAdapterRead("true", LDValue.of(true)); - verifyTypeAdapterRead("1", LDValue.of(1)); - verifyTypeAdapterRead("\"x\"", LDValue.of("x")); - verifyTypeAdapterRead("[1,2]", LDValue.buildArray().add(1).add(2).build()); - verifyTypeAdapterRead("{\"a\":1}", LDValue.buildObject().put("a", 1).build()); - - try (JsonReader r = new JsonReader(new StringReader("]"))) { - try { - LDValueTypeAdapter.INSTANCE.read(r); - } catch (MalformedJsonException e) {} - } - } - - private static void verifyTypeAdapterRead(String jsonString, LDValue expectedValue) throws Exception { - try (JsonReader r = new JsonReader(new StringReader(jsonString))) { - assertEquals(expectedValue, LDValueTypeAdapter.INSTANCE.read(r)); - } - } } diff --git a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java index ce9f7f2..9ea6255 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java @@ -1,18 +1,11 @@ package com.launchdarkly.sdk.json; -import com.launchdarkly.sdk.LDUser; -import com.launchdarkly.sdk.LDValue; - import com.google.gson.JsonElement; import com.google.gson.JsonNull; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonToken; -import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.LDValue; import org.junit.Test; -import java.io.StringReader; -import java.io.StringWriter; import java.util.HashMap; import java.util.Map; @@ -31,15 +24,15 @@ public class LDGsonTest { @Test public void valueToJsonElement() { - verifyValueSerialization(LDValue.ofNull()); - verifyValueSerialization(LDValue.of(true)); - verifyValueSerialization(LDValue.of(false)); - verifyValueSerialization(LDValue.of("x")); - verifyValueSerialization(LDValue.of("say \"hello\"")); - verifyValueSerialization(LDValue.of(2)); - verifyValueSerialization(LDValue.of(2.5f)); - verifyValueSerialization(JsonTestHelpers.nestedArrayValue()); - verifyValueSerialization(JsonTestHelpers.nestedObjectValue()); + verifyValueToJsonElement(LDValue.ofNull()); + verifyValueToJsonElement(LDValue.of(true)); + verifyValueToJsonElement(LDValue.of(false)); + verifyValueToJsonElement(LDValue.of("x")); + verifyValueToJsonElement(LDValue.of("say \"hello\"")); + verifyValueToJsonElement(LDValue.of(2)); + verifyValueToJsonElement(LDValue.of(2.5f)); + verifyValueToJsonElement(JsonTestHelpers.nestedArrayValue()); + verifyValueToJsonElement(JsonTestHelpers.nestedObjectValue()); assertEquals(JsonNull.INSTANCE, LDGson.valueToJsonElement(null)); } @@ -55,88 +48,17 @@ public void valueMapToJsonElementMap() { JsonTestHelpers.assertJsonEquals(js1, js2); } - @Test - public void complexObjectToJsonTree() { - LDUser user = new LDUser.Builder("userkey").name("name") - .custom("attr1", LDValue.ofNull()) - .custom("atrt2", LDValue.of(true)) - .custom("attr3", LDValue.of(false)) - .custom("attr4", LDValue.of(0)) - .custom("attr5", LDValue.of(1)) - .custom("attr6", LDValue.of("")) - .custom("attr7", LDValue.of("x")) - .custom("attr8", JsonTestHelpers.nestedArrayValue()) - .custom("attr9", JsonTestHelpers.nestedObjectValue()) - .build(); - JsonElement j = JsonTestHelpers.configureGson().toJsonTree(user); - String js = JsonSerialization.gson.toJson(j); - assertEquals(LDValue.parse(JsonSerialization.serialize(user)), LDValue.parse(js)); - } - - @Test - public void testInternalReaderAdapter() throws Exception { - // This and testInternalWriterAdapter verify that all of our reader/writer delegation - // methods work as expected, regardless of whether or not they are exercised indirectly - // by our other unit tests. - String json = "[null,false,true,1,2,3,\"x\",{\"a\":false}]"; - try (StringReader sr = new StringReader(json)) { - try (JsonReader jr0 = new JsonReader(sr)) { - try (JsonReader jr = new LDGson.DelegatingJsonReaderAdapter(jr0)) { - jr.beginArray(); - assertEquals(true, jr.hasNext()); - jr.nextNull(); - assertEquals(JsonToken.BOOLEAN, jr.peek()); - jr.skipValue(); - assertEquals(true, jr.nextBoolean()); - assertEquals(1d, jr.nextDouble(), 0); - assertEquals(2, jr.nextInt()); - assertEquals(3, jr.nextLong()); - assertEquals("x", jr.nextString()); - jr.beginObject(); - assertEquals("a", jr.nextName()); - assertEquals(false, jr.nextBoolean()); - jr.endObject(); - jr.endArray(); - } - } - } - } - - @Test - public void testInternalWriterAdapter() throws Exception { - try (StringWriter sw = new StringWriter()) { - try (JsonWriter jw0 = new JsonWriter(sw)) { - try (JsonWriter jw = new LDGson.DelegatingJsonWriterAdapter(jw0)) { - jw.beginArray(); - jw.nullValue(); - jw.value(true); - jw.value(Boolean.valueOf(true)); - jw.value((Boolean)null); - jw.value((double)1); - jw.value((long)2); - jw.value(Float.valueOf(3)); - jw.value("x"); - jw.beginObject(); - jw.name("a"); - jw.value(false); - jw.endObject(); - jw.jsonValue("123"); - jw.endArray(); - jw.flush(); - } - } - String expected = "[null,true,true,null,1,2,3,\"x\",{\"a\":false},123]"; - JsonTestHelpers.assertJsonEquals(expected, sw.toString()); - } - } - static void verifyValueSerialization(LDValue value) { - JsonElement j1 = LDGson.valueToJsonElement(value); - String js1 = JsonSerialization.gson.toJson(j1); - JsonTestHelpers.assertJsonEquals(value.toJsonString(), js1); + verifyValueToJsonElement(value); JsonElement j2 = JsonTestHelpers.configureGson().toJsonTree(value); String js2 = JsonSerialization.gson.toJson(j2); JsonTestHelpers.assertJsonEquals(value.toJsonString(), js2); } + + static void verifyValueToJsonElement(LDValue value) { + JsonElement j1 = LDGson.valueToJsonElement(value); + String js1 = JsonSerialization.gson.toJson(j1); + JsonTestHelpers.assertJsonEquals(value.toJsonString(), js1); + } } diff --git a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java index 95370da..18c7334 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java @@ -10,7 +10,6 @@ import static com.launchdarkly.sdk.TestHelpers.builtInAttributes; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; -import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; @SuppressWarnings("javadoc") @@ -22,8 +21,6 @@ public void minimalJsonEncoding() throws Exception { verifyDeserializeInvalidJson(LDUser.class, "3"); verifyDeserializeInvalidJson(LDUser.class, "{\"key\":\"userkey\",\"name\":3"); - - verifySerialize((LDUser)null, "null"); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java index ac955be..55e5511 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java @@ -16,7 +16,6 @@ public class LDValueJsonSerializationTest extends BaseTest { @Test public void jsonEncodingForNull() throws Exception { verifySerialize(LDValue.ofNull(), "null"); - verifySerialize((LDValue)null, "null"); } @Test @@ -28,8 +27,8 @@ public void jsonEncodingForNonNullValues() throws Exception { verifyValueSerialization(LDValue.of(2), "2"); verifyValueSerialization(LDValue.of(2.5f), "2.5"); verifyValueSerialization(LDValue.of(2.5d), "2.5"); - verifyValueSerialization(JsonTestHelpers.basicArrayValue(), "[2,\"x\"]"); - verifyValueSerialization(JsonTestHelpers.basicObjectValue(), "{\"x\":2}"); + verifyValueSerialization(LDValue.buildArray().add(2).add("x").build(), "[2,\"x\"]"); + verifyValueSerialization(LDValue.buildObject().put("x", 2).build(), "{\"x\":2}"); verifyDeserializeInvalidJson(LDValue.class, "]"); } From 36fe80ff72c39135afa3033575eb4a16f1022e5d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 16 Apr 2021 16:32:22 -0700 Subject: [PATCH 42/91] unrevert some JSON improvements from previous revert --- .../sdk/EvaluationDetailTypeAdapterFactory.java | 1 - .../launchdarkly/sdk/EvaluationReasonTypeAdapter.java | 6 +++--- src/main/java/com/launchdarkly/sdk/Helpers.java | 9 +++++++++ src/main/java/com/launchdarkly/sdk/LDValue.java | 2 +- .../sdk/json/LDValueJsonSerializationTest.java | 6 ++++-- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java index b6d66c7..a982856 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java @@ -87,6 +87,5 @@ public EvaluationDetail read(JsonReader in) throws IOException { return EvaluationDetail.fromValue(value, variation, reason); } - } } diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java index a505eb9..389f0d8 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java @@ -8,7 +8,7 @@ import java.io.IOException; -import static com.launchdarkly.sdk.Helpers.readNonNullableString; +import static com.launchdarkly.sdk.Helpers.readEnum; import static com.launchdarkly.sdk.Helpers.readNullableString; final class EvaluationReasonTypeAdapter extends TypeAdapter { @@ -29,7 +29,7 @@ static EvaluationReason parse(JsonReader reader) throws IOException { String key = reader.nextName(); switch (key) { // COVERAGE: may have spurious "branches missed" warning, see https://stackoverflow.com/questions/28013717/eclemma-branch-coverage-for-switch-7-of-19-missed case "kind": - kind = Enum.valueOf(EvaluationReason.Kind.class, readNonNullableString(reader)); + kind = readEnum(EvaluationReason.Kind.class, reader); break; case "ruleIndex": ruleIndex = reader.nextInt(); @@ -41,7 +41,7 @@ static EvaluationReason parse(JsonReader reader) throws IOException { prereqKey = reader.nextString(); break; case "errorKind": - errorKind = Enum.valueOf(EvaluationReason.ErrorKind.class, readNonNullableString(reader)); + errorKind = readEnum(EvaluationReason.ErrorKind.class, reader); break; default: reader.skipValue(); // ignore any unexpected property diff --git a/src/main/java/com/launchdarkly/sdk/Helpers.java b/src/main/java/com/launchdarkly/sdk/Helpers.java index f602d0b..aafe4f7 100644 --- a/src/main/java/com/launchdarkly/sdk/Helpers.java +++ b/src/main/java/com/launchdarkly/sdk/Helpers.java @@ -56,4 +56,13 @@ static String readNonNullableString(JsonReader reader) throws IOException { throw new JsonParseException("expected string value"); } } + + static > T readEnum(Class enumClass, JsonReader reader) throws IOException { + String s = readNonNullableString(reader); + try { + return Enum.valueOf(enumClass, s); + } catch (IllegalArgumentException e) { + throw new JsonParseException(String.format("unsupported value \"{}\" for {}", s, enumClass)); + } + } } diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index 63440ea..956dc7f 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -192,7 +192,7 @@ public static ObjectBuilder buildObject() { */ public static LDValue parse(String json) { try { - return JsonSerialization.deserialize(json, LDValue.class); + return LDValue.normalize(JsonSerialization.deserialize(json, LDValue.class)); } catch (SerializationException e) { throw new RuntimeException(e); } diff --git a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java index 55e5511..04cd551 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java @@ -16,6 +16,8 @@ public class LDValueJsonSerializationTest extends BaseTest { @Test public void jsonEncodingForNull() throws Exception { verifySerialize(LDValue.ofNull(), "null"); + verifySerialize((LDValue)null, "null"); + assertEquals(LDValue.ofNull(), LDValue.parse("null")); } @Test @@ -27,8 +29,8 @@ public void jsonEncodingForNonNullValues() throws Exception { verifyValueSerialization(LDValue.of(2), "2"); verifyValueSerialization(LDValue.of(2.5f), "2.5"); verifyValueSerialization(LDValue.of(2.5d), "2.5"); - verifyValueSerialization(LDValue.buildArray().add(2).add("x").build(), "[2,\"x\"]"); - verifyValueSerialization(LDValue.buildObject().put("x", 2).build(), "{\"x\":2}"); + verifyValueSerialization(JsonTestHelpers.basicArrayValue(), "[2,\"x\"]"); + verifyValueSerialization(JsonTestHelpers.basicObjectValue(), "{\"x\":2}"); verifyDeserializeInvalidJson(LDValue.class, "]"); } From 9d50c7e247d9210e202920dcc5df325bb607958c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 19 Apr 2021 15:31:09 -0700 Subject: [PATCH 43/91] improve Gson integration using reader/writer delegation - take 2 --- build.gradle | 5 +- .../launchdarkly/sdk/LDValueTypeAdapter.java | 4 +- .../sdk/json/GsonReaderAdapter.java | 91 +++++++++ .../sdk/json/GsonWriterAdapter.java | 140 ++++++++++++++ .../sdk/json/JsonSerialization.java | 34 +++- .../com/launchdarkly/sdk/json/LDGson.java | 177 ++++++++++++++++-- .../com/launchdarkly/sdk/LDValueTest.java | 27 +++ .../sdk/json/JsonTestHelpers.java | 4 +- .../com/launchdarkly/sdk/json/LDGsonTest.java | 117 ++++++++++-- .../sdk/json/LDUserJsonSerializationTest.java | 3 + 10 files changed, 550 insertions(+), 52 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java create mode 100644 src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java diff --git a/build.gradle b/build.gradle index 73ee6ad..fffba90 100644 --- a/build.gradle +++ b/build.gradle @@ -89,8 +89,9 @@ jacocoTestCoverageVerification { "EvaluationReasonTypeAdapter.parse(com.google.gson.stream.JsonReader)": 1, "EvaluationDetailTypeAdapterFactory.EvaluationDetailTypeAdapter.read(com.google.gson.stream.JsonReader)": 1, "LDValue.equals(java.lang.Object)": 1, - "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)": 3, - "json.JsonSerialization.getDeserializableClasses()": -1 + "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)": 1, + "json.JsonSerialization.getDeserializableClasses()": -1, + "json.LDGson.LDTypeAdapter.write(com.google.gson.stream.JsonWriter, java.lang.Object)": 1 ] knownMissedLinesForMethods.each { partialSignature, maxMissedLines -> diff --git a/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java index bd5f419..88eb956 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java @@ -35,7 +35,6 @@ public LDValue read(JsonReader reader) throws IOException { case BOOLEAN: return LDValue.of(reader.nextBoolean()); case NULL: - // COVERAGE: this branch won't be reached because Gson does not call the TypeAdapter when there's a null. reader.nextNull(); return LDValue.ofNull(); case NUMBER: @@ -43,7 +42,8 @@ public LDValue read(JsonReader reader) throws IOException { case STRING: return LDValue.of(reader.nextString()); default: - // COVERAGE: this branch won't be reached because Gson does not call the TypeAdapter if the next token isn't well-formed JSON. + // COVERAGE: this branch won't be reached because Gson does not call the TypeAdapter if the next token + // isn't any of the above. return null; } } diff --git a/src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java b/src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java new file mode 100644 index 0000000..e96fc28 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java @@ -0,0 +1,91 @@ +package com.launchdarkly.sdk.json; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; + +import java.io.CharArrayReader; +import java.io.IOException; +import java.io.Reader; + +// This type is a bridge between the Gson classes on the application classpath and the Gson classes +// that are used internally. +// +// In some SDK distributions, there is an internal set of Gson classes that have modified (shaded) +// class names, to ensure that the SDK can use its own version of Gson without conflicting with the +// application. If so, all references to Gson classes in the SDK code will be transformed to the +// shaded class names *except* within the LDGson class. This means that our Gson TypeAdapters can't +// interact directly with a JsonReader or JsonWriter that is provided by the application. +// +// GsonReaderAdapter and GsonWriterAdapter, since they are declared outside of the LDGson class, +// *will* have all Gson types in their class/method signatures shaded if we are using shading. +// Therefore, they can be used with our internal Gson logic. But the actual implementation of their +// methods is done by a subclass that is an inner class of LDGson-- so, that class can interact +// with unshaded Gson classes provided by the application. +// +// So, if all com.google.gson classes are being shaded to com.launchdarkly.shaded.com.google.gson, +// then the base class of GsonReaderAdapter is com.launchdarkly.shaded.com.google.gson.JsonReader; +// the class LDGson.DelegatingJsonReaderAdapter is a GsonReaderAdapter, so it is also a +// com.launchdarkly.shaded.com.google.gson.JsonReader; but references to JsonReader within the +// implementation of LDGson.DelegatingJsonReaderAdapter are to com.google.json.JsonReader. +// +// In SDK distributions that do not use shading, these types are not really necessary, but their +// overhead is minimal so we use them in all cases. +abstract class GsonReaderAdapter extends JsonReader { + private static final JsonToken[] TOKEN_VALUES = JsonToken.values(); + + GsonReaderAdapter() { + super(makeStubReader()); + } + + private static final Reader makeStubReader() { + // The JsonReader constructor requires a non-null Reader, but we won't actually be using it. + // Unfortunately Java 7 doesn't implement a completely no-op Reader. + return new CharArrayReader(new char[0]); + } + + @Override + abstract public void beginArray() throws IOException; + + @Override + abstract public void beginObject() throws IOException; + + @Override + abstract public void endArray() throws IOException; + + @Override + abstract public void endObject() throws IOException; + + @Override + abstract public boolean hasNext() throws IOException; + + @Override + abstract public boolean nextBoolean() throws IOException; + + @Override + abstract public double nextDouble() throws IOException; + + @Override + abstract public int nextInt() throws IOException; + + @Override + abstract public long nextLong() throws IOException; + + @Override + abstract public String nextName() throws IOException; + + @Override + abstract public void nextNull() throws IOException; + + @Override + abstract public String nextString() throws IOException; + + @Override + public JsonToken peek() throws IOException { + return TOKEN_VALUES[peekInternal()]; + } + + @Override + abstract public void skipValue() throws IOException; + + abstract protected int peekInternal() throws IOException; // should return the ordinal of the JsonToken enum +} diff --git a/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java b/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java new file mode 100644 index 0000000..29e4236 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java @@ -0,0 +1,140 @@ +package com.launchdarkly.sdk.json; + +import com.google.gson.stream.JsonWriter; + +import java.io.CharArrayWriter; +import java.io.IOException; +import java.io.Writer; + +// This type is a bridge between the Gson classes on the application classpath and the Gson classes +// that are used internally. +// +// In some SDK distributions, there is an internal set of Gson classes that have modified (shaded) +// class names, to ensure that the SDK can use its own version of Gson without conflicting with the +// application. If so, all references to Gson classes in the SDK code will be transformed to the +// shaded class names *except* within the LDGson class. This means that our Gson TypeAdapters can't +// interact directly with a JsonReader or JsonWriter that is provided by the application. +// +// GsonReaderAdapter and GsonWriterAdapter, since they are declared outside of the LDGson class, +// *will* have all Gson types in their class/method signatures shaded if we are using shading. +// Therefore, they can be used with our internal Gson logic. But the actual implementation of their +// methods is done by a subclass that is an inner class of LDGson-- so, that class can interact +// with unshaded Gson classes provided by the application. +// +// So, if all com.google.gson classes are being shaded to com.launchdarkly.shaded.com.google.gson, +// then the base class of GsonWriterAdapter is com.launchdarkly.shaded.com.google.gson.JsonWriter; +// the class LDGson.DelegatingJsonWriterAdapter is a GsonWriterAdapter, so it is also a +// com.launchdarkly.shaded.com.google.gson.JsonWriter; but references to JsonWriter within the +// implementation of LDGson.DelegatingJsonWriterAdapter are to com.google.json.JsonWriter. +// +// In SDK distributions that do not use shading, these types are not really necessary, but their +// overhead is minimal so we use them in all cases. +abstract class GsonWriterAdapter extends JsonWriter { + GsonWriterAdapter() { + super(makeStubWriter()); + } + + private static final Writer makeStubWriter() { + // The JsonWriter constructor requires a non-null Writer, but we won't actually be using it. + // Unfortunately Java 7 doesn't implement a completely no-op Writer. + return new CharArrayWriter(0); + } + + @Override + public JsonWriter beginArray() throws IOException { + beginArrayInternal(); + return this; + } + + @Override + public JsonWriter beginObject() throws IOException { + beginObjectInternal(); + return this; + } + + @Override + public JsonWriter endArray() throws IOException { + endArrayInternal(); + return this; + } + + @Override + public JsonWriter endObject() throws IOException { + endObjectInternal(); + return this; + } + + @Override + public JsonWriter jsonValue(String value) throws IOException { + jsonValueInternal(value); + return this; + } + + @Override + public JsonWriter name(String name) throws IOException { + nameInternal(name); + return this; + } + + @Override + public JsonWriter nullValue() throws IOException { + valueInternalNull(); + return this; + } + + @Override + public JsonWriter value(boolean value) throws IOException { + valueInternalBool(value); + return this; + } + + @Override + public JsonWriter value(Boolean value) throws IOException { + if (value == null) { + valueInternalNull(); + } else { + valueInternalBool(value.booleanValue()); + } + return this; + } + + @Override + public JsonWriter value(double value) throws IOException { + valueInternalDouble(value); + return this; + } + + @Override + public JsonWriter value(long value) throws IOException { + valueInternalLong(value); + return this; + } + + @Override + public JsonWriter value(Number value) throws IOException { + valueInternalNumber(value); + return this; + } + + @Override + public JsonWriter value(String value) throws IOException { + valueInternalString(value); + return this; + } + + @Override + public void close() throws IOException {} + + protected abstract void beginArrayInternal() throws IOException; + protected abstract void beginObjectInternal() throws IOException; + protected abstract void endArrayInternal() throws IOException; + protected abstract void endObjectInternal() throws IOException; + protected abstract void jsonValueInternal(String value) throws IOException; + protected abstract void nameInternal(String name) throws IOException; + protected abstract void valueInternalNull() throws IOException; + protected abstract void valueInternalBool(boolean value) throws IOException; + protected abstract void valueInternalDouble(double value) throws IOException; + protected abstract void valueInternalLong(long value) throws IOException; + protected abstract void valueInternalNumber(Number value) throws IOException; + protected abstract void valueInternalString(String value) throws IOException; +} diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index 63b8169..8cadeb2 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -35,7 +35,7 @@ private JsonSerialization() {} static final List> knownDeserializableClasses = new ArrayList<>(); - static final Gson gson = new Gson(); + private static final Gson gson = new Gson(); /** * Converts an object to its JSON representation. @@ -86,13 +86,31 @@ static T deserializeInternal(String json, Class objectClass) throws Seria } } - // Used internally from LDGson - static T deserializeInternalGson(String json, Type objectType) throws SerializationException { - try { - return gson.fromJson(json, objectType); - } catch (Exception e) { - throw new SerializationException(e); - } + // Used internally to delegate to gson.toJson() in a way that will work correctly regardless of + // whether we're shading the Gson types or not. + // + // The issue is this. In the Java SDK, all references to Gson types anywhere in the SDK *except* + // in the LDGson class will have their packages rewritten from com.google.gson to + // com.launchdarkly.shaded.com.google.gson. That's the whole reason GsonWriterAdapter exists. + // However, the shading logic is not quite smart enough to adjust method signatures that have + // already been copied into non-shaded classes, so if the LDGson code (which is immune from + // shading) tries to call any methods on JsonSerialization.gson (which was originally an + // instance of c.g.gson.Gson, but now is an instance of c.l.s.c.g.gson.Gson)-- or tries to call + // any method that took a parameter of type c.g.gson.stream.JsonWriter, but has since been + // rewritten to take a parameter of type c.l.s.c.g.gson.stream.JsonWriter-- the call will fail + // because the actual method signature doesn't match what the caller expected. + // + // The solution is to add this delegating method whose external surface doesn't contain any + // references to classes whose package names will be rewritten; while JsonSerialization and + // GsonWriterAdapter will have code *inside* them modified by shading, their own signatures + // won't change. + static void serializeToGsonInternal(Object value, Class type, GsonWriterAdapter writer) { + gson.toJson(value, type, writer); + } + + // See comment on serializeToGsonInternal. + static T deserializeFromGsonInternal(GsonReaderAdapter adapter, Type type) { + return gson.fromJson(adapter, type); } /** diff --git a/src/main/java/com/launchdarkly/sdk/json/LDGson.java b/src/main/java/com/launchdarkly/sdk/json/LDGson.java index e041c05..28ea231 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDGson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDGson.java @@ -5,7 +5,6 @@ import com.google.gson.JsonElement; import com.google.gson.JsonNull; import com.google.gson.JsonObject; -import com.google.gson.JsonParseException; import com.google.gson.JsonPrimitive; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; @@ -146,41 +145,181 @@ private static class LDTypeAdapterFactory implements TypeAdapterFactory { @Override public TypeAdapter create(Gson gson, TypeToken type) { if (JsonSerializable.class.isAssignableFrom(type.getRawType())) { - return new LDTypeAdapter(gson, type.getType()); + return new LDTypeAdapter(type.getType()); } return null; } } private static class LDTypeAdapter extends TypeAdapter { - private final Gson gson; private final Type objectType; - LDTypeAdapter(Gson gson, Type objectType) { - this.gson = gson; + LDTypeAdapter(Type objectType) { this.objectType = objectType; } @Override public void write(JsonWriter out, T value) throws IOException { - String json = JsonSerialization.serializeInternal(value); - out.jsonValue(json); + if (value == null) { + // COVERAGE: we don't expect this to ever happen, since Gson normally doesn't bother to call + // the type adapter for any null value; it's just a sanity check. + out.nullValue(); + } else { + JsonSerialization.serializeToGsonInternal(value, value.getClass(), new DelegatingJsonWriterAdapter(out)); + } } @Override public T read(JsonReader in) throws IOException { - // This implementation is inefficient because we can't assume our internal Gson instance can - // use this JsonReader directly; instead we have to read the next JSON value, convert it to a - // string, and then ask our JsonSerialization to parse it back from a string. - JsonElement jsonTree = gson.fromJson(in, JsonElement.class); - String jsonString = gson.toJson(jsonTree); - try { - // Calling the Gson overload that takes a Type rather than a Class (even though a Class *is* a - // Type) allows it to take generic type parameters into account for EvaluationDetail. - return JsonSerialization.deserializeInternalGson(jsonString, objectType); - } catch (SerializationException e) { - throw new JsonParseException(e.getCause()); - } + return JsonSerialization.deserializeFromGsonInternal(new DelegatingJsonReaderAdapter(in), objectType); + } + } + + // See comments on GsonReaderAdapter for the reason this type exists. + static class DelegatingJsonReaderAdapter extends GsonReaderAdapter { + private final JsonReader reader; + + DelegatingJsonReaderAdapter(JsonReader reader) { + this.reader = reader; + } + + @Override + public void beginArray() throws IOException { + reader.beginArray(); + } + + @Override + public void beginObject() throws IOException { + reader.beginObject(); + } + + @Override + public void endArray() throws IOException { + reader.endArray(); + } + + @Override + public void endObject() throws IOException { + reader.endObject(); + } + + @Override + public boolean hasNext() throws IOException { + return reader.hasNext(); + } + + @Override + public boolean nextBoolean() throws IOException { + return reader.nextBoolean(); + } + + @Override + public double nextDouble() throws IOException { + return reader.nextDouble(); + } + + @Override + public int nextInt() throws IOException { + return reader.nextInt(); + } + + @Override + public long nextLong() throws IOException { + return reader.nextLong(); + } + + @Override + public String nextName() throws IOException { + return reader.nextName(); + } + + @Override + public void nextNull() throws IOException { + reader.nextNull(); + } + + @Override + public String nextString() throws IOException { + return reader.nextString(); + } + + @Override + public void skipValue() throws IOException { + reader.skipValue(); + } + + @Override + protected int peekInternal() throws IOException { + return reader.peek().ordinal(); + } + } + + // See comments on GsonWriterAdapter for the reason this type exists. + static class DelegatingJsonWriterAdapter extends GsonWriterAdapter { + private final JsonWriter writer; + + DelegatingJsonWriterAdapter(JsonWriter writer) { + this.writer = writer; + } + + @Override + protected void beginArrayInternal() throws IOException { + writer.beginArray(); + } + + @Override + protected void beginObjectInternal() throws IOException { + writer.beginObject(); + } + + @Override + protected void endArrayInternal() throws IOException { + writer.endArray(); + } + + @Override + protected void endObjectInternal() throws IOException { + writer.endObject(); + } + + @Override + protected void jsonValueInternal(String value) throws IOException { + writer.jsonValue(value); + } + + @Override + protected void nameInternal(String name) throws IOException { + writer.name(name); + } + + @Override + protected void valueInternalNull() throws IOException { + writer.nullValue(); + } + + @Override + protected void valueInternalBool(boolean value) throws IOException { + writer.value(value); + } + + @Override + protected void valueInternalDouble(double value) throws IOException { + writer.value(value); + } + + @Override + protected void valueInternalLong(long value) throws IOException { + writer.value(value); + } + + @Override + protected void valueInternalNumber(Number value) throws IOException { + writer.value(value); + } + + @Override + protected void valueInternalString(String value) throws IOException { + writer.value(value); } } } diff --git a/src/test/java/com/launchdarkly/sdk/LDValueTest.java b/src/test/java/com/launchdarkly/sdk/LDValueTest.java index af49bd9..39bdac9 100644 --- a/src/test/java/com/launchdarkly/sdk/LDValueTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueTest.java @@ -1,9 +1,12 @@ package com.launchdarkly.sdk; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.MalformedJsonException; import com.launchdarkly.sdk.json.SerializationException; import org.junit.Test; +import java.io.StringReader; import java.util.List; import static java.util.Arrays.asList; @@ -258,4 +261,28 @@ public void parseThrowsRuntimeExceptionForMalformedJson() { assertThat(e.getCause(), instanceOf(SerializationException.class)); } } + + @Test + public void testLowLevelTypeAdapter() throws Exception { + // This test ensures full test coverage of LDValueTypeAdapter code paths that might not + // be exercised indirectly by other tests. + verifyTypeAdapterRead("null", LDValue.ofNull()); + verifyTypeAdapterRead("true", LDValue.of(true)); + verifyTypeAdapterRead("1", LDValue.of(1)); + verifyTypeAdapterRead("\"x\"", LDValue.of("x")); + verifyTypeAdapterRead("[1,2]", LDValue.buildArray().add(1).add(2).build()); + verifyTypeAdapterRead("{\"a\":1}", LDValue.buildObject().put("a", 1).build()); + + try (JsonReader r = new JsonReader(new StringReader("]"))) { + try { + LDValueTypeAdapter.INSTANCE.read(r); + } catch (MalformedJsonException e) {} + } + } + + private static void verifyTypeAdapterRead(String jsonString, LDValue expectedValue) throws Exception { + try (JsonReader r = new JsonReader(new StringReader(jsonString))) { + assertEquals(expectedValue, LDValueTypeAdapter.INSTANCE.read(r)); + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java index f82c9d9..31c4cbd 100644 --- a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java @@ -14,6 +14,8 @@ @SuppressWarnings("javadoc") public abstract class JsonTestHelpers extends BaseTest { + static final Gson gson = new Gson(); + // Note that when we verify the behavior of Gson with LDGson in this project's unit tests, that // is not an adequate test for whether the adapters will work in the Java SDK where there is the // additional issue of Gson types being shaded. The Java SDK project must do its own basic tests @@ -85,7 +87,7 @@ public static void assertJsonEquals(String expectedJsonString, String actualJson } public static JsonElement parseElement(String jsonString) { - return JsonSerialization.gson.fromJson(jsonString, JsonElement.class); + return gson.fromJson(jsonString, JsonElement.class); } public static LDValue basicArrayValue() { diff --git a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java index 9ea6255..e44ba41 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java @@ -2,17 +2,23 @@ import com.google.gson.JsonElement; import com.google.gson.JsonNull; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import org.junit.Test; +import java.io.StringReader; +import java.io.StringWriter; import java.util.HashMap; import java.util.Map; import static org.junit.Assert.assertEquals; @SuppressWarnings("javadoc") -public class LDGsonTest { +public class LDGsonTest { // Note that these unit tests don't fully prove that our Gson integration works as intended // in SDK distributions that shade the Gson classes, because the tests for this project are // run on the unmodified code with real Gson classes in the classpath. SDKs that use shading @@ -24,15 +30,15 @@ public class LDGsonTest { @Test public void valueToJsonElement() { - verifyValueToJsonElement(LDValue.ofNull()); - verifyValueToJsonElement(LDValue.of(true)); - verifyValueToJsonElement(LDValue.of(false)); - verifyValueToJsonElement(LDValue.of("x")); - verifyValueToJsonElement(LDValue.of("say \"hello\"")); - verifyValueToJsonElement(LDValue.of(2)); - verifyValueToJsonElement(LDValue.of(2.5f)); - verifyValueToJsonElement(JsonTestHelpers.nestedArrayValue()); - verifyValueToJsonElement(JsonTestHelpers.nestedObjectValue()); + verifyValueSerialization(LDValue.ofNull()); + verifyValueSerialization(LDValue.of(true)); + verifyValueSerialization(LDValue.of(false)); + verifyValueSerialization(LDValue.of("x")); + verifyValueSerialization(LDValue.of("say \"hello\"")); + verifyValueSerialization(LDValue.of(2)); + verifyValueSerialization(LDValue.of(2.5f)); + verifyValueSerialization(JsonTestHelpers.nestedArrayValue()); + verifyValueSerialization(JsonTestHelpers.nestedObjectValue()); assertEquals(JsonNull.INSTANCE, LDGson.valueToJsonElement(null)); } @@ -41,24 +47,95 @@ public void valueMapToJsonElementMap() { Map m1 = new HashMap<>(); m1.put("a", LDValue.of(true)); m1.put("b", LDValue.of(1)); - String js1 = JsonSerialization.gson.toJson(m1); + String js1 = JsonTestHelpers.gson.toJson(m1); Map m2 = LDGson.valueMapToJsonElementMap(m1); - String js2 = JsonSerialization.gson.toJson(m2); + String js2 = JsonTestHelpers.gson.toJson(m2); JsonTestHelpers.assertJsonEquals(js1, js2); } - static void verifyValueSerialization(LDValue value) { - verifyValueToJsonElement(value); - - JsonElement j2 = JsonTestHelpers.configureGson().toJsonTree(value); - String js2 = JsonSerialization.gson.toJson(j2); - JsonTestHelpers.assertJsonEquals(value.toJsonString(), js2); + @Test + public void complexObjectToJsonTree() { + LDUser user = new LDUser.Builder("userkey").name("name") + .custom("attr1", LDValue.ofNull()) + .custom("atrt2", LDValue.of(true)) + .custom("attr3", LDValue.of(false)) + .custom("attr4", LDValue.of(0)) + .custom("attr5", LDValue.of(1)) + .custom("attr6", LDValue.of("")) + .custom("attr7", LDValue.of("x")) + .custom("attr8", JsonTestHelpers.nestedArrayValue()) + .custom("attr9", JsonTestHelpers.nestedObjectValue()) + .build(); + JsonElement j = JsonTestHelpers.configureGson().toJsonTree(user); + String js = JsonTestHelpers.gson.toJson(j); + assertEquals(LDValue.parse(JsonSerialization.serialize(user)), LDValue.parse(js)); + } + + @Test + public void testInternalReaderAdapter() throws Exception { + // This and testInternalWriterAdapter verify that all of our reader/writer delegation + // methods work as expected, regardless of whether or not they are exercised indirectly + // by our other unit tests. + String json = "[null,false,true,1,2,3,\"x\",{\"a\":false}]"; + try (StringReader sr = new StringReader(json)) { + try (JsonReader jr0 = new JsonReader(sr)) { + try (JsonReader jr = new LDGson.DelegatingJsonReaderAdapter(jr0)) { + jr.beginArray(); + assertEquals(true, jr.hasNext()); + jr.nextNull(); + assertEquals(JsonToken.BOOLEAN, jr.peek()); + jr.skipValue(); + assertEquals(true, jr.nextBoolean()); + assertEquals(1d, jr.nextDouble(), 0); + assertEquals(2, jr.nextInt()); + assertEquals(3, jr.nextLong()); + assertEquals("x", jr.nextString()); + jr.beginObject(); + assertEquals("a", jr.nextName()); + assertEquals(false, jr.nextBoolean()); + jr.endObject(); + jr.endArray(); + } + } + } } - static void verifyValueToJsonElement(LDValue value) { + @Test + public void testInternalWriterAdapter() throws Exception { + try (StringWriter sw = new StringWriter()) { + try (JsonWriter jw0 = new JsonWriter(sw)) { + try (JsonWriter jw = new LDGson.DelegatingJsonWriterAdapter(jw0)) { + jw.beginArray(); + jw.nullValue(); + jw.value(true); + jw.value(Boolean.valueOf(true)); + jw.value((Boolean)null); + jw.value((double)1); + jw.value((long)2); + jw.value(Float.valueOf(3)); + jw.value("x"); + jw.beginObject(); + jw.name("a"); + jw.value(false); + jw.endObject(); + jw.jsonValue("123"); + jw.endArray(); + jw.flush(); + } + } + String expected = "[null,true,true,null,1,2,3,\"x\",{\"a\":false},123]"; + JsonTestHelpers.assertJsonEquals(expected, sw.toString()); + } + } + + static void verifyValueSerialization(LDValue value) { JsonElement j1 = LDGson.valueToJsonElement(value); - String js1 = JsonSerialization.gson.toJson(j1); + String js1 = JsonTestHelpers.gson.toJson(j1); JsonTestHelpers.assertJsonEquals(value.toJsonString(), js1); + + JsonElement j2 = JsonTestHelpers.configureGson().toJsonTree(value); + String js2 = JsonTestHelpers.gson.toJson(j2); + JsonTestHelpers.assertJsonEquals(value.toJsonString(), js2); } } diff --git a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java index 18c7334..95370da 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java @@ -10,6 +10,7 @@ import static com.launchdarkly.sdk.TestHelpers.builtInAttributes; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; @SuppressWarnings("javadoc") @@ -21,6 +22,8 @@ public void minimalJsonEncoding() throws Exception { verifyDeserializeInvalidJson(LDUser.class, "3"); verifyDeserializeInvalidJson(LDUser.class, "{\"key\":\"userkey\",\"name\":3"); + + verifySerialize((LDUser)null, "null"); } @Test From b8ace9bd93025a66b21d0a9b43ba3d81b1e6c257 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 21 Apr 2021 14:15:16 -0700 Subject: [PATCH 44/91] better Jackson adapter + misc JSON test improvements (#18) --- build.gradle | 4 +- .../EvaluationDetailTypeAdapterFactory.java | 4 + .../java/com/launchdarkly/sdk/Helpers.java | 2 +- .../com/launchdarkly/sdk/json/LDJackson.java | 251 +++++++++++++++++- ...EvaluationDetailJsonSerializationTest.java | 15 +- .../sdk/json/JsonTestHelpers.java | 19 +- .../com/launchdarkly/sdk/json/LDGsonTest.java | 31 ++- .../launchdarkly/sdk/json/LDJacksonTest.java | 97 +++++++ 8 files changed, 400 insertions(+), 23 deletions(-) create mode 100644 src/test/java/com/launchdarkly/sdk/json/LDJacksonTest.java diff --git a/build.gradle b/build.gradle index fffba90..470f688 100644 --- a/build.gradle +++ b/build.gradle @@ -87,11 +87,11 @@ jacocoTestCoverageVerification { // The key for each of these items is the complete method signature minus the "com.launchdarkly.sdk." prefix. "EvaluationReason.error(com.launchdarkly.sdk.EvaluationReason.ErrorKind)": 1, "EvaluationReasonTypeAdapter.parse(com.google.gson.stream.JsonReader)": 1, - "EvaluationDetailTypeAdapterFactory.EvaluationDetailTypeAdapter.read(com.google.gson.stream.JsonReader)": 1, "LDValue.equals(java.lang.Object)": 1, "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)": 1, "json.JsonSerialization.getDeserializableClasses()": -1, - "json.LDGson.LDTypeAdapter.write(com.google.gson.stream.JsonWriter, java.lang.Object)": 1 + "json.LDGson.LDTypeAdapter.write(com.google.gson.stream.JsonWriter, java.lang.Object)": 1, + "json.LDJackson.GsonReaderToJacksonParserAdapter.peekInternal()": 3 ] knownMissedLinesForMethods.each { partialSignature, maxMissedLines -> diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java index a982856..f151b49 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java @@ -59,6 +59,7 @@ public void write(JsonWriter out, EvaluationDetail value) throws IOException out.endObject(); } + @SuppressWarnings("unchecked") @Override public EvaluationDetail read(JsonReader in) throws IOException { T value = null; @@ -85,6 +86,9 @@ public EvaluationDetail read(JsonReader in) throws IOException { } in.endObject(); + if (value == null && valueType == LDValue.class) { + value = (T)LDValue.ofNull(); // normalize to get around gson's habit of skipping the TypeAdapter for nulls + } return EvaluationDetail.fromValue(value, variation, reason); } } diff --git a/src/main/java/com/launchdarkly/sdk/Helpers.java b/src/main/java/com/launchdarkly/sdk/Helpers.java index aafe4f7..5231aef 100644 --- a/src/main/java/com/launchdarkly/sdk/Helpers.java +++ b/src/main/java/com/launchdarkly/sdk/Helpers.java @@ -62,7 +62,7 @@ static > T readEnum(Class enumClass, JsonReader reader) thr try { return Enum.valueOf(enumClass, s); } catch (IllegalArgumentException e) { - throw new JsonParseException(String.format("unsupported value \"{}\" for {}", s, enumClass)); + throw new JsonParseException(String.format("unsupported value \"%s\" for %s", s, enumClass)); } } } diff --git a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java index 56541bd..2e4017a 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java @@ -1,11 +1,10 @@ package com.launchdarkly.sdk.json; import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonLocation; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.TreeNode; +import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonSerializer; @@ -73,8 +72,9 @@ private static class LDJacksonSerializer extends JsonSerializer extends J @Override public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException { - // This implementation is inefficient because our internal Gson instance can't use Jackson's - // streaming parser directly; instead we have to read the next JSON value, convert it to a - // string, and then ask our JsonSerialization to parse it back from a string. - JsonLocation loc = p.getCurrentLocation(); - TreeNode jsonTree = p.readValueAsTree(); - String jsonString = jsonTree.toString(); - try { - return JsonSerialization.deserialize(jsonString, objectClass); - } catch (SerializationException e) { - throw new JsonParseException(p, "invalid JSON encoding for " + objectClass.getSimpleName(), loc, e); + try (GsonReaderToJacksonParserAdapter adapter = new GsonReaderToJacksonParserAdapter(p)) { + try { + return JsonSerialization.deserializeFromGsonInternal(adapter, objectClass); + } catch (com.google.gson.JsonParseException e) { + throw new JsonParseException(p, e.getMessage()); + } + } + } + } + + static class GsonReaderToJacksonParserAdapter extends GsonReaderAdapter { + private final JsonParser parser; + private boolean atToken = true; + + GsonReaderToJacksonParserAdapter(JsonParser parser) { + this.parser = parser; + } + + @Override + public void beginArray() throws IOException { + requireToken(JsonToken.START_ARRAY, JsonToken.START_ARRAY, "array"); + } + + @Override + public void beginObject() throws IOException { + requireToken(JsonToken.START_OBJECT, JsonToken.START_OBJECT, "object"); + } + + @Override + public void endArray() throws IOException { + requireToken(JsonToken.END_ARRAY, JsonToken.END_ARRAY, "end of array"); + } + + @Override + public void endObject() throws IOException { + requireToken(JsonToken.END_OBJECT, JsonToken.END_OBJECT, "end of object"); + } + + @Override + public boolean hasNext() throws IOException { + JsonToken t = peekToken(); + return t != JsonToken.END_ARRAY && t != JsonToken.END_OBJECT; + } + + @Override + public boolean nextBoolean() throws IOException { + requireToken(JsonToken.VALUE_FALSE, JsonToken.VALUE_TRUE, "boolean"); + return parser.getBooleanValue(); + } + + @Override + public double nextDouble() throws IOException { + requireToken(JsonToken.VALUE_NUMBER_FLOAT, JsonToken.VALUE_NUMBER_INT, "number"); + return parser.getDoubleValue(); + } + + @Override + public int nextInt() throws IOException { + requireToken(JsonToken.VALUE_NUMBER_FLOAT, JsonToken.VALUE_NUMBER_INT, "number"); + return parser.getIntValue(); + } + + @Override + public long nextLong() throws IOException { + requireToken(JsonToken.VALUE_NUMBER_FLOAT, JsonToken.VALUE_NUMBER_INT, "number"); + return parser.getLongValue(); + } + + @Override + public String nextName() throws IOException { + requireToken(JsonToken.FIELD_NAME, JsonToken.FIELD_NAME, "property name"); + return parser.getCurrentName(); + } + + @Override + public void nextNull() throws IOException { + requireToken(JsonToken.VALUE_NULL, JsonToken.VALUE_NULL, "null"); + } + + @Override + public String nextString() throws IOException { + requireToken(JsonToken.VALUE_STRING, JsonToken.VALUE_NULL, "string"); + return parser.getValueAsString(); + } + + @Override + public void skipValue() throws IOException { + consumeToken(); + parser.skipChildren(); + } + + @Override + protected int peekInternal() throws IOException { + JsonToken t = peekToken(); + if (t == null) { + return com.google.gson.stream.JsonToken.END_DOCUMENT.ordinal(); + } + com.google.gson.stream.JsonToken gt; + switch (peekToken()) { + case END_ARRAY: + gt = com.google.gson.stream.JsonToken.END_ARRAY; + break; + case END_OBJECT: + gt = com.google.gson.stream.JsonToken.END_OBJECT; + break; + case FIELD_NAME: + gt = com.google.gson.stream.JsonToken.NAME; + break; + case NOT_AVAILABLE: + gt = com.google.gson.stream.JsonToken.END_DOCUMENT; // COVERAGE: shouldn't be reachable + break; + case START_ARRAY: + gt = com.google.gson.stream.JsonToken.BEGIN_ARRAY; + break; + case START_OBJECT: + gt = com.google.gson.stream.JsonToken.BEGIN_OBJECT; + break; + case VALUE_FALSE: + gt = com.google.gson.stream.JsonToken.BOOLEAN; + break; + case VALUE_NULL: + gt = com.google.gson.stream.JsonToken.NULL; + break; + case VALUE_NUMBER_FLOAT: + gt = com.google.gson.stream.JsonToken.NUMBER; + break; + case VALUE_NUMBER_INT: + gt = com.google.gson.stream.JsonToken.NUMBER; + break; + case VALUE_STRING: + gt = com.google.gson.stream.JsonToken.STRING; + break; + case VALUE_TRUE: + gt = com.google.gson.stream.JsonToken.BOOLEAN; + break; + default: + gt = com.google.gson.stream.JsonToken.END_DOCUMENT; // COVERAGE: shouldn't be reachable } + return gt.ordinal(); + } + + private void requireToken(JsonToken type, JsonToken alternateType, String expectedDesc) throws IOException { + JsonToken t = consumeToken(); + if (t != type && t != alternateType) { + throw new JsonParseException(parser, "expected " + expectedDesc); + } + } + + private JsonToken peekToken() throws IOException { + if (!atToken) { + atToken = true; + return parser.nextToken(); + } + return parser.currentToken(); + } + + private JsonToken consumeToken() throws IOException { + if (atToken) { + atToken = false; + return parser.currentToken(); + } + return parser.nextToken(); + } + } + + static class GsonWriterToJacksonGeneratorAdapter extends GsonWriterAdapter { + private final JsonGenerator gen; + + GsonWriterToJacksonGeneratorAdapter(JsonGenerator gen) { + this.gen = gen; + } + + @Override + protected void beginArrayInternal() throws IOException { + gen.writeStartArray(); + } + + @Override + protected void beginObjectInternal() throws IOException { + gen.writeStartObject(); + } + + @Override + protected void endArrayInternal() throws IOException { + gen.writeEndArray(); + } + + @Override + protected void endObjectInternal() throws IOException { + gen.writeEndObject(); + } + + @Override + protected void jsonValueInternal(String value) throws IOException { + gen.writeRawValue(value); + } + + @Override + protected void nameInternal(String name) throws IOException { + gen.writeFieldName(name); + } + + @Override + protected void valueInternalNull() throws IOException { + gen.writeNull(); + } + + @Override + protected void valueInternalBool(boolean value) throws IOException { + gen.writeBoolean(value); + } + + @Override + protected void valueInternalDouble(double value) throws IOException { + gen.writeNumber(value); + } + + @Override + protected void valueInternalLong(long value) throws IOException { + gen.writeNumber(value); + } + + @Override + protected void valueInternalNumber(Number value) throws IOException { + if (value == null) { + gen.writeNull(); + } else { + gen.writeNumber(value.doubleValue()); + } + } + + @Override + protected void valueInternalString(String value) throws IOException { + gen.writeString(value); } } } diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java index 93634c7..63b75eb 100644 --- a/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java @@ -11,6 +11,7 @@ import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.CLIENT_NOT_READY; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializationDeserializesTo; @SuppressWarnings("javadoc") public class EvaluationDetailJsonSerializationTest extends BaseTest { @@ -24,12 +25,22 @@ public void detailJsonSerializations() throws Exception { EvaluationDetail.fromValue(LDValue.of("x"), NO_VARIATION, EvaluationReason.error(CLIENT_NOT_READY)), "{\"value\":\"x\",\"reason\":{\"kind\":\"ERROR\",\"errorKind\":\"CLIENT_NOT_READY\"}}"); - verifySerialize(EvaluationDetail.fromValue((String)null, 1, EvaluationReason.off()), - "{\"variationIndex\":1,\"reason\":{\"kind\":\"OFF\"}}"); // Gson will omit the "value: null" + // EvaluationDetail that contains some type other than LDValue that Gson knows how to serialize + verifySerialize(EvaluationDetail.fromValue("x", 1, EvaluationReason.off()), + "{\"value\":\"x\",\"variationIndex\":1,\"reason\":{\"kind\":\"OFF\"}}"); + + // If the value is a null reference (rather than LDValue.ofNull()) it should serialize as a null + // rather than throwing a NPE + verifySerializationDeserializesTo(EvaluationDetail.fromValue((LDValue)null, 1, EvaluationReason.off()), + EvaluationDetail.fromValue(LDValue.ofNull(), 1, EvaluationReason.off())); // Due to how generic types work in Gson, simply calling Gson.fromJson> will *not* // use any custom deserialization for type T; it will behave as if T were LDValue. However, it should // correctly pick up the type signature if you deserialize an object that contains such a value. That // scenario is covered in ReflectiveFrameworksTest. + + // Unknown properties are ignored + JsonTestHelpers.verifyDeserialize(EvaluationDetail.fromValue(LDValue.of("x"), 1, EvaluationReason.off()), + "{\"pleaseIgnoreThis\":[1,2,3],\"value\":\"x\",\"variationIndex\":1,\"reason\":{\"kind\":\"OFF\"}}"); } } diff --git a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java index 31c4cbd..f80546e 100644 --- a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java @@ -6,6 +6,7 @@ import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import com.google.gson.JsonParseException; +import com.google.gson.JsonSyntaxException; import com.launchdarkly.sdk.BaseTest; import com.launchdarkly.sdk.LDValue; @@ -47,6 +48,17 @@ public static void verifySerialize(T instance, Stri assertJsonEquals(expectedJsonString, configureJacksonMapper().writeValueAsString(instance)); } + public static void verifySerializationDeserializesTo(T instance, T resultInstance) throws Exception { + assertEquals(resultInstance, + JsonSerialization.deserialize(JsonSerialization.serialize(instance), resultInstance.getClass())); + + assertEquals(resultInstance, + configureGson().fromJson(configureGson().toJson(instance), resultInstance.getClass())); + + assertEquals(resultInstance, + configureJacksonMapper().readValue(configureJacksonMapper().writeValueAsString(instance), resultInstance.getClass())); + } + @SuppressWarnings("unchecked") public static void verifyDeserialize(T instance, String expectedJsonString) throws Exception { // Special handling here because in real life you wouldn't be trying to deserialize something as for @@ -83,7 +95,12 @@ public static void verifyDeserializeInvalidJson(Cla } public static void assertJsonEquals(String expectedJsonString, String actualJsonString) { - assertEquals(parseElement(expectedJsonString), parseElement(actualJsonString)); + try { + JsonElement actualParsed = parseElement(actualJsonString); + assertEquals(parseElement(expectedJsonString), actualParsed); + } catch (JsonSyntaxException e) { + fail("expected JSON: " + expectedJsonString + ", but got malformed JSON: " + actualJsonString); + } } public static JsonElement parseElement(String jsonString) { diff --git a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java index e44ba41..362334f 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java @@ -2,9 +2,12 @@ import com.google.gson.JsonElement; import com.google.gson.JsonNull; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSyntaxException; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonToken; import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; @@ -16,6 +19,8 @@ import java.util.Map; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; @SuppressWarnings("javadoc") public class LDGsonTest { @@ -77,7 +82,7 @@ public void testInternalReaderAdapter() throws Exception { // This and testInternalWriterAdapter verify that all of our reader/writer delegation // methods work as expected, regardless of whether or not they are exercised indirectly // by our other unit tests. - String json = "[null,false,true,1,2,3,\"x\",{\"a\":false}]"; + String json = "[null,false,true,1.5,2,3,\"x\",{\"a\":false}]"; try (StringReader sr = new StringReader(json)) { try (JsonReader jr0 = new JsonReader(sr)) { try (JsonReader jr = new LDGson.DelegatingJsonReaderAdapter(jr0)) { @@ -87,15 +92,19 @@ public void testInternalReaderAdapter() throws Exception { assertEquals(JsonToken.BOOLEAN, jr.peek()); jr.skipValue(); assertEquals(true, jr.nextBoolean()); - assertEquals(1d, jr.nextDouble(), 0); + assertEquals(1.5d, jr.nextDouble(), 0); assertEquals(2, jr.nextInt()); assertEquals(3, jr.nextLong()); assertEquals("x", jr.nextString()); jr.beginObject(); + assertEquals(true, jr.hasNext()); assertEquals("a", jr.nextName()); assertEquals(false, jr.nextBoolean()); + assertEquals(false, jr.hasNext()); jr.endObject(); + assertEquals(false, jr.hasNext()); jr.endArray(); + assertEquals(JsonToken.END_DOCUMENT, jr.peek()); } } } @@ -114,6 +123,7 @@ public void testInternalWriterAdapter() throws Exception { jw.value((double)1); jw.value((long)2); jw.value(Float.valueOf(3)); + jw.value((Float)null); jw.value("x"); jw.beginObject(); jw.name("a"); @@ -124,11 +134,26 @@ public void testInternalWriterAdapter() throws Exception { jw.flush(); } } - String expected = "[null,true,true,null,1,2,3,\"x\",{\"a\":false},123]"; + String expected = "[null,true,true,null,1,2,3,null,\"x\",{\"a\":false},123]"; JsonTestHelpers.assertJsonEquals(expected, sw.toString()); } } + @Test(expected=JsonSyntaxException.class) + public void parseExceptionIsThrownForMalformedJson() throws Exception { + JsonTestHelpers.configureGson().fromJson("[1:,2]", LDValue.class); + } + + @Test + public void parseExceptionIsThrownForIllegalValue() throws Exception { + try { + JsonTestHelpers.configureGson().fromJson("{\"kind\":\"NOTGOOD\"}", EvaluationReason.class); + fail("expected exception"); + } catch (JsonParseException e) { + assertTrue(e.getMessage(), e.getMessage().contains("unsupported value \"NOTGOOD\"")); + } + } + static void verifyValueSerialization(LDValue value) { JsonElement j1 = LDGson.valueToJsonElement(value); String js1 = JsonTestHelpers.gson.toJson(j1); diff --git a/src/test/java/com/launchdarkly/sdk/json/LDJacksonTest.java b/src/test/java/com/launchdarkly/sdk/json/LDJacksonTest.java new file mode 100644 index 0000000..415d8c5 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/LDJacksonTest.java @@ -0,0 +1,97 @@ +package com.launchdarkly.sdk.json; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; + +import org.junit.Test; + +import java.io.StringWriter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class LDJacksonTest { + @Test + public void testInternalReaderAdapter() throws Exception { + // This and testInternalWriterAdapter verify that all of our reader/writer delegation + // methods work as expected, regardless of whether or not they are exercised indirectly + // by our other unit tests. + String json = "[null,false,true,1.5,2,3,\"x\",{\"a\":false}]"; + JsonParser p = new JsonFactory().createParser(json); + p.nextToken(); + try (JsonReader jr = new LDJackson.GsonReaderToJacksonParserAdapter(p)) { + jr.beginArray(); + assertEquals(true, jr.hasNext()); + jr.nextNull(); + assertEquals(JsonToken.BOOLEAN, jr.peek()); + jr.skipValue(); + assertEquals(true, jr.nextBoolean()); + assertEquals(1.5d, jr.nextDouble(), 0); + assertEquals(2, jr.nextInt()); + assertEquals(3, jr.nextLong()); + assertEquals("x", jr.nextString()); + jr.beginObject(); + assertEquals(true, jr.hasNext()); + assertEquals("a", jr.nextName()); + assertEquals(false, jr.nextBoolean()); + assertEquals(false, jr.hasNext()); + jr.endObject(); + assertEquals(false, jr.hasNext()); + jr.endArray(); + assertEquals(JsonToken.END_DOCUMENT, jr.peek()); + } + } + + @Test + public void testInternalWriterAdapter() throws Exception { + try (StringWriter sw = new StringWriter()) { + JsonGenerator gen = new JsonFactory().createGenerator(sw); + try (JsonWriter jw = new LDJackson.GsonWriterToJacksonGeneratorAdapter(gen)) { + jw.beginArray(); + jw.nullValue(); + jw.value(true); + jw.value(Boolean.valueOf(true)); + jw.value((Boolean)null); + jw.value((double)1); + jw.value((long)2); + jw.value(Float.valueOf(3)); + jw.value((Float)null); + jw.value("x"); + jw.beginObject(); + jw.name("a"); + jw.value(false); + jw.endObject(); + jw.jsonValue("123"); + jw.endArray(); + jw.flush(); + } + gen.flush(); + String expected = "[null,true,true,null,1,2,3,null,\"x\",{\"a\":false},123]"; + JsonTestHelpers.assertJsonEquals(expected, sw.toString()); + } + } + + @Test(expected=JsonParseException.class) + public void parseExceptionIsThrownForMalformedJson() throws Exception { + JsonTestHelpers.configureJacksonMapper().readValue("[1:,2]", LDValue.class); + } + + @Test + public void parseExceptionIsThrownForIllegalValue() throws Exception { + try { + JsonTestHelpers.configureJacksonMapper().readValue("{\"kind\":\"NOTGOOD\"}", EvaluationReason.class); + fail("expected exception"); + } catch (JsonParseException e) { + assertTrue(e.getMessage(), e.getMessage().contains("unsupported value \"NOTGOOD\"")); + } + } +} From cd9525eb45cf820268cb7a8b11f19c270492128f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 21 Apr 2021 14:16:20 -0700 Subject: [PATCH 45/91] omit redundant method call (this commit was mistakenly left out of the previous merge) --- src/main/java/com/launchdarkly/sdk/json/LDJackson.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java index 2e4017a..7e5f102 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java @@ -185,7 +185,7 @@ protected int peekInternal() throws IOException { return com.google.gson.stream.JsonToken.END_DOCUMENT.ordinal(); } com.google.gson.stream.JsonToken gt; - switch (peekToken()) { + switch (t) { case END_ARRAY: gt = com.google.gson.stream.JsonToken.END_ARRAY; break; From 08c734f11fe55910af0169ef5829560d434fe2a4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 22 Apr 2021 14:30:22 -0700 Subject: [PATCH 46/91] avoid unnecessarily adding ".0" to JSON numbers (#25) --- .../launchdarkly/sdk/json/GsonWriterAdapter.java | 16 +++++++++++++--- .../java/com/launchdarkly/sdk/json/LDGson.java | 5 ----- .../com/launchdarkly/sdk/json/LDJackson.java | 9 --------- .../com/launchdarkly/sdk/json/LDGsonTest.java | 6 ++++-- .../com/launchdarkly/sdk/json/LDJacksonTest.java | 6 ++++-- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java b/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java index 29e4236..361766e 100644 --- a/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java @@ -100,7 +100,14 @@ public JsonWriter value(Boolean value) throws IOException { @Override public JsonWriter value(double value) throws IOException { - valueInternalDouble(value); + // The following logic avoids inconsistent output by not letting the underlying framework + // decide to append .0 to integer values + long asLong = (long)value; + if (value == (double)asLong) { + valueInternalLong(asLong); + } else { + valueInternalDouble(value); + } return this; } @@ -112,7 +119,11 @@ public JsonWriter value(long value) throws IOException { @Override public JsonWriter value(Number value) throws IOException { - valueInternalNumber(value); + if (value == null) { + valueInternalNull(); + } else { + value(value.doubleValue()); + } return this; } @@ -135,6 +146,5 @@ public void close() throws IOException {} protected abstract void valueInternalBool(boolean value) throws IOException; protected abstract void valueInternalDouble(double value) throws IOException; protected abstract void valueInternalLong(long value) throws IOException; - protected abstract void valueInternalNumber(Number value) throws IOException; protected abstract void valueInternalString(String value) throws IOException; } diff --git a/src/main/java/com/launchdarkly/sdk/json/LDGson.java b/src/main/java/com/launchdarkly/sdk/json/LDGson.java index 28ea231..4212e75 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDGson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDGson.java @@ -312,11 +312,6 @@ protected void valueInternalLong(long value) throws IOException { writer.value(value); } - @Override - protected void valueInternalNumber(Number value) throws IOException { - writer.value(value); - } - @Override protected void valueInternalString(String value) throws IOException { writer.value(value); diff --git a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java index 7e5f102..cbe1669 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java @@ -309,15 +309,6 @@ protected void valueInternalLong(long value) throws IOException { gen.writeNumber(value); } - @Override - protected void valueInternalNumber(Number value) throws IOException { - if (value == null) { - gen.writeNull(); - } else { - gen.writeNumber(value.doubleValue()); - } - } - @Override protected void valueInternalString(String value) throws IOException { gen.writeString(value); diff --git a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java index 362334f..d493d8e 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java @@ -121,8 +121,10 @@ public void testInternalWriterAdapter() throws Exception { jw.value(Boolean.valueOf(true)); jw.value((Boolean)null); jw.value((double)1); + jw.value((double)1.5); jw.value((long)2); jw.value(Float.valueOf(3)); + jw.value(Float.valueOf(3.5f)); jw.value((Float)null); jw.value("x"); jw.beginObject(); @@ -134,8 +136,8 @@ public void testInternalWriterAdapter() throws Exception { jw.flush(); } } - String expected = "[null,true,true,null,1,2,3,null,\"x\",{\"a\":false},123]"; - JsonTestHelpers.assertJsonEquals(expected, sw.toString()); + String expected = "[null,true,true,null,1,1.5,2,3,3.5,null,\"x\",{\"a\":false},123]"; + assertEquals(expected, sw.toString().replace(" ", "")); } } diff --git a/src/test/java/com/launchdarkly/sdk/json/LDJacksonTest.java b/src/test/java/com/launchdarkly/sdk/json/LDJacksonTest.java index 415d8c5..6fc67ba 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDJacksonTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDJacksonTest.java @@ -62,8 +62,10 @@ public void testInternalWriterAdapter() throws Exception { jw.value(Boolean.valueOf(true)); jw.value((Boolean)null); jw.value((double)1); + jw.value((double)1.5); jw.value((long)2); jw.value(Float.valueOf(3)); + jw.value(Float.valueOf(3.5f)); jw.value((Float)null); jw.value("x"); jw.beginObject(); @@ -75,8 +77,8 @@ public void testInternalWriterAdapter() throws Exception { jw.flush(); } gen.flush(); - String expected = "[null,true,true,null,1,2,3,null,\"x\",{\"a\":false},123]"; - JsonTestHelpers.assertJsonEquals(expected, sw.toString()); + String expected = "[null,true,true,null,1,1.5,2,3,3.5,null,\"x\",{\"a\":false},123]"; + assertEquals(expected, sw.toString().replace(" ", "")); } } From 6a5a5f61493a8827827d2e8124df8aafb8d02f1c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 5 May 2021 10:12:35 -0700 Subject: [PATCH 47/91] update Gradle to 6.8.3 --- build-android.gradle | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 55616 -> 58695 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 29 ++++++++++------------- gradlew.bat | 3 +++ 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/build-android.gradle b/build-android.gradle index e7b046b..e716e42 100644 --- a/build-android.gradle +++ b/build-android.gradle @@ -9,7 +9,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.6.0' + classpath 'com.android.tools.build:gradle:4.2.0' } } // This Gradle script is used only when we are running tests in an Android environment to verify diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 5c2d1cf016b3885f6930543d57b744ea8c220a1a..f3d88b1c2faf2fc91d853cd5d4242b5547257070 100644 GIT binary patch delta 22808 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 delta 20005 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 nvEpR}Ffj;rE}sKLhd|1%F5dI!=ox-rLy2 Date: Wed, 5 May 2021 11:03:34 -0700 Subject: [PATCH 48/91] Kotlinize main build script --- build.gradle | 217 ----------------------------------------------- build.gradle.kts | 193 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 217 deletions(-) delete mode 100644 build.gradle create mode 100644 build.gradle.kts diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 470f688..0000000 --- a/build.gradle +++ /dev/null @@ -1,217 +0,0 @@ - -buildscript { - repositories { - mavenCentral() - mavenLocal() - } -} - -plugins { - id "java" - id "java-library" - id "checkstyle" - id "jacoco" - id "signing" - id "maven-publish" - id "de.marcphilipp.nexus-publish" version "0.3.0" - id "io.codearte.nexus-staging" version "0.21.2" - id "org.ajoberstar.git-publish" version "2.1.3" - id "idea" -} - -repositories { - mavenLocal() - // Before LaunchDarkly release artifacts get synced to Maven Central they are here along with snapshots: - maven { url "https://oss.sonatype.org/content/groups/public/" } - mavenCentral() -} - -configurations.all { - // check for updates every build for dependencies with: 'changing: true' - resolutionStrategy.cacheChangingModulesFor 0, 'seconds' -} - -apply from: 'build-shared.gradle' - -checkstyle { - configFile file("${project.rootDir}/checkstyle.xml") -} - -// custom tasks for creating source/javadoc jars -task sourcesJar(type: Jar, dependsOn: classes) { - classifier = 'sources' - from sourceSets.main.allSource -} - -task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = 'javadoc' - from javadoc.destinationDir -} - -javadoc { - // Force the Javadoc build to fail if there are any Javadoc warnings. See: https://discuss.gradle.org/t/javadoc-fail-on-warning/18141/3 - // The '-quiet' as second argument is actually a hack, - // since the one parameter 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') -} - -artifacts { - archives jar, sourcesJar, javadocJar -} - -test { - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - showStandardStreams = true - exceptionFormat = 'full' - } -} - -jacocoTestReport { // code coverage report - reports { - xml.enabled - csv.enabled true - html.enabled true - } -} - -jacocoTestCoverageVerification { - // See notes in CONTRIBUTING.md on code coverage. Unfortunately we can't configure line-by-line code - // coverage overrides within the source code itself, because Jacoco operates on bytecode. - violationRules { rules -> - def knownMissedLinesForMethods = [ - // The key for each of these items is the complete method signature minus the "com.launchdarkly.sdk." prefix. - "EvaluationReason.error(com.launchdarkly.sdk.EvaluationReason.ErrorKind)": 1, - "EvaluationReasonTypeAdapter.parse(com.google.gson.stream.JsonReader)": 1, - "LDValue.equals(java.lang.Object)": 1, - "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)": 1, - "json.JsonSerialization.getDeserializableClasses()": -1, - "json.LDGson.LDTypeAdapter.write(com.google.gson.stream.JsonWriter, java.lang.Object)": 1, - "json.LDJackson.GsonReaderToJacksonParserAdapter.peekInternal()": 3 - ] - - knownMissedLinesForMethods.each { partialSignature, maxMissedLines -> - if (maxMissedLines > 0) { // < 0 means skip entire method - rules.rule { - element = "METHOD" - includes = [ "com.launchdarkly.sdk." + partialSignature ] - limit { - counter = "LINE" - value = "MISSEDCOUNT" - maximum = maxMissedLines - } - } - } - } - - // General rule that we should expect 100% test coverage; exclude any methods that have overrides above - rule { - element = "METHOD" - limit { - counter = "LINE" - value = "MISSEDCOUNT" - maximum = 0 - } - excludes = knownMissedLinesForMethods.collect { partialSignature, maxMissedLines -> - "com.launchdarkly.sdk." + partialSignature - } - } - } -} - -idea { - module { - downloadJavadoc = true - downloadSources = true - } -} - -nexusStaging { - packageGroup = "com.launchdarkly" - numberOfRetries = 40 // we've seen extremely long delays in closing repositories -} - -publishing { - publications { - mavenJava(MavenPublication) { - from components.java - - groupId = 'com.launchdarkly' - artifactId = 'launchdarkly-java-sdk-common' - - artifact sourcesJar - artifact javadocJar - - pom { - name = 'launchdarkly-java-sdk-common' - description = 'LaunchDarkly SDK Java Common Classes' - url = 'https://github.com/launchdarkly/java-sdk-common' - licenses { - license { - name = 'The Apache License, Version 2.0' - url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' - } - } - developers { - developer { - name = 'LaunchDarkly' - email = 'team@launchdarkly.com' - } - } - scm { - connection = 'scm:git:git://github.com/launchdarkly/java-sdk-common.git' - developerConnection = 'scm:git:ssh:git@github.com:launchdarkly/java-sdk-common.git' - url = 'https://github.com/launchdarkly/java-sdk-common' - } - } - - // We are deliberately hiding our dependencies in the pom, for the following reasons: - // - // 1. Gson: While java-sdk-common does need Gson in order to work, the LaunchDarkly SDKs that use - // java-sdk-common have different strategies for packaging Gson. The Android SDK exposes it as a - // regular dependency; the Java server-side SDK embeds and shades Gson and does not expose it as - // a dependency. So we are leaving it up to the SDK to provide Gson in some way. - // - // 2. Jackson: The SDKs do not use, require, or embed Jackson; we provide the LDJackson class as - // a convenience for applications that do use Jackson. So we do not want it to be a transitive - // dependency. - pom.withXml { - asNode().dependencies.forEach { it.value = "" } - } - } - } - repositories { - mavenLocal() - } -} - -nexusPublishing { - clientTimeout = java.time.Duration.ofMinutes(2) // we've seen extremely long delays in creating repositories - repositories { - sonatype { - username = ossrhUsername - password = ossrhPassword - } - } -} - -signing { - sign publishing.publications.mavenJava -} - -tasks.withType(Sign) { - onlyIf { !"1".equals(project.findProperty("LD_SKIP_SIGNING")) } // so we can build jars for testing in CI -} - -gitPublish { - repoUri = 'git@github.com:launchdarkly/java-sdk-common.git' - branch = 'gh-pages' - contents { - from javadoc - } - commitMessage = 'publishing javadocs' -} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..022c162 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,193 @@ +import java.time.Duration +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.external.javadoc.CoreJavadocOptions + +// These values come from gradle.properties +val ossrhUsername: String by project +val ossrhPassword: String by project + +buildscript { + repositories { + mavenCentral() + mavenLocal() + } +} + +plugins { // see Dependencies.kt in buildSrc + java + "java-library" + checkstyle + signing + "maven-publish" + idea + jacoco + id("de.marcphilipp.nexus-publish") version "0.3.0" + id("io.codearte.nexus-staging") version "0.30.0" +} + +repositories { + mavenLocal() + // Before LaunchDarkly release artifacts get synced to Maven Central they are here along with snapshots: + maven { url = uri("https://oss.sonatype.org/content/groups/public/") } + mavenCentral() +} + +apply { from("build-shared.gradle") } + +java { + withJavadocJar() + withSourcesJar() +} + +// dependencies { // see Dependencies.kt in buildSrc +// // Dependencies will not be exposed in the pom - see below in pom.withXml block +// Libs.implementation.forEach(::implementation) +// Libs.testImplementation.forEach(::testImplementation) +// } + +checkstyle { + configFile = file("${project.rootDir}/checkstyle.xml") +} + +tasks.javadoc.configure { + // Force the Javadoc build to fail if there are any Javadoc warnings. See: https://discuss.gradle.org/t/javadoc-fail-on-warning/18141/3 + // See JDK-8200363 (https://bugs.openjdk.java.net/browse/JDK-8200363) + // for information about the -Xwerror option. + (options as CoreJavadocOptions).addStringOption("Xwerror") +} + +tasks.test.configure { + testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + exceptionFormat = TestExceptionFormat.FULL + } +} + +tasks.jacocoTestReport.configure { + reports { + xml.isEnabled = true + csv.isEnabled = true + html.isEnabled = true + } +} + +tasks.jacocoTestCoverageVerification.configure { + // See notes in CONTRIBUTING.md on code coverage. Unfortunately we can't configure line-by-line code + // coverage overrides within the source code itself, because Jacoco operates on bytecode. + violationRules { + val knownMissedLinesForMethods = mapOf( + "EvaluationReason.error(com.launchdarkly.sdk.EvaluationReason.ErrorKind)" to 1, + "EvaluationReasonTypeAdapter.parse(com.google.gson.stream.JsonReader)" to 1, + "LDValue.equals(java.lang.Object)" to 1, + "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)" to 1, + "json.JsonSerialization.getDeserializableClasses()" to -1, + "json.LDGson.LDTypeAdapter.write(com.google.gson.stream.JsonWriter, java.lang.Object)" to 1, + "json.LDJackson.GsonReaderToJacksonParserAdapter.peekInternal()" to 3 + ) + + knownMissedLinesForMethods.forEach { (signature, maxMissedLines) -> + if (maxMissedLines > 0) { // < 0 means skip entire method + rule { + element = "METHOD" + includes = listOf("com.launchdarkly.sdk." + signature) + limit { + counter = "LINE" + value = "MISSEDCOUNT" + maximum = maxMissedLines.toBigDecimal() + } + } + } + } + + // General rule that we should expect 100% test coverage; exclude any methods that have overrides above + rule { + element = "METHOD" + limit { + counter = "LINE" + value = "MISSEDCOUNT" + maximum = 0.toBigDecimal() + } + excludes = knownMissedLinesForMethods.map { (signature, maxMissedLines) -> + "com.launchdarkly.sdk." + signature } + } + } +} + +idea { + module { + isDownloadJavadoc = true + isDownloadSources = true + } +} + +nexusStaging { + packageGroup = "com.launchdarkly" + numberOfRetries = 40 // we've seen extremely long delays in closing repositories +} + +publishing { + publications { + create("mavenJava") { + from(components["java"]) + + groupId = "com.launchdarkly" + artifactId = "launchdarkly-java-sdk-common" + + pom { + name.set("launchdarkly-java-sdk-common") + description.set("LaunchDarkly SDK Java Common Classes") + url.set("https://github.com/launchdarkly/java-sdk-common") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + name.set("LaunchDarkly") + email.set("team@launchdarkly.com") + } + } + scm { + connection.set("scm:git:git://github.com/launchdarkly/java-sdk-common.git") + developerConnection.set("scm:git:ssh:git@github.com:launchdarkly/java-sdk-common.git") + url.set("https://github.com/launchdarkly/java-sdk-common") + } + } + + // We are deliberately hiding our dependencies in the pom, for the following reasons: + // + // 1. Gson: While java-sdk-common does need Gson in order to work, the LaunchDarkly SDKs that use + // java-sdk-common have different strategies for packaging Gson. The Android SDK exposes it as a + // regular dependency; the Java server-side SDK embeds and shades Gson and does not expose it as + // a dependency. So we are leaving it up to the SDK to provide Gson in some way. + // + // 2. Jackson: The SDKs do not use, require, or embed Jackson; we provide the LDJackson class as + // a convenience for applications that do use Jackson. So we do not want it to be a transitive + // dependency. + pom.withXml { + val root = asElement() + root.removeChild(root.getElementsByTagName("dependencies").item(0)) + } + } + } + repositories { + mavenLocal() + } +} + +nexusPublishing { + clientTimeout.set(Duration.ofMinutes(2)) // we've seen extremely long delays in creating repositories + repositories { + sonatype { + username.set(ossrhUsername) + password.set(ossrhPassword) + } + } +} + +signing { + sign(publishing.publications["mavenJava"]) +} From 9088dff4dbff5f2bb0df35b78f690188447ee9cf Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 5 May 2021 11:08:52 -0700 Subject: [PATCH 49/91] rm obsolete comments --- build.gradle.kts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 022c162..fd551d3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,7 +13,7 @@ buildscript { } } -plugins { // see Dependencies.kt in buildSrc +plugins { java "java-library" checkstyle @@ -39,12 +39,6 @@ java { withSourcesJar() } -// dependencies { // see Dependencies.kt in buildSrc -// // Dependencies will not be exposed in the pom - see below in pom.withXml block -// Libs.implementation.forEach(::implementation) -// Libs.testImplementation.forEach(::testImplementation) -// } - checkstyle { configFile = file("${project.rootDir}/checkstyle.xml") } From b5c338e1d12cea5fb2c2271024cf3202c5b17a70 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 14 Jun 2021 10:02:13 -0700 Subject: [PATCH 50/91] bump Jackson compile-time dependency to 2.10.5.1 due to CVE-2020-25649 (#30) * bump Jackson compile-time dependency to 2.10.5.1 due to CVE-2020-25649 * 2.10.5.1 patch only exists in one of the Jackson modules --- build-shared.gradle | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/build-shared.gradle b/build-shared.gradle index a6ec4eb..2fcc8ea 100644 --- a/build-shared.gradle +++ b/build-shared.gradle @@ -17,7 +17,8 @@ ext { ext.versions = [ "gson": "2.7", - "jackson": "2.10.0" + "jacksonCore": "2.10.5", + "jacksonDatabind": "2.10.5.1" ] ext.libraries = [:] @@ -25,8 +26,8 @@ ext.libraries = [:] dependencies { // Dependencies will not be exposed in the pom - see below in pom.withXml block implementation "com.google.code.gson:gson:${versions.gson}" - implementation "com.fasterxml.jackson.core:jackson-core:${versions.jackson}" - implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" + implementation "com.fasterxml.jackson.core:jackson-core:${versions.jacksonCore}" + implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jacksonDatabind}" testImplementation "org.hamcrest:hamcrest-library:1.3" testImplementation "junit:junit:4.12" From b9116a8ef788df0e3bce13311ee0a1bee34f98a2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 14 Jun 2021 16:53:58 -0700 Subject: [PATCH 51/91] exclude Gson & Jackson from published dependencies in a more correct way (#31) --- build-android.gradle | 8 ++++++++ build-shared.gradle | 26 ++++++++++++++++++++++---- build.gradle.kts | 23 ++++++++--------------- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/build-android.gradle b/build-android.gradle index e716e42..a7901ec 100644 --- a/build-android.gradle +++ b/build-android.gradle @@ -60,6 +60,14 @@ android { } dependencies { + // See note in build-shared.gradle on the purpose of "privateImplementation". The + // Android Gradle plugin doesn't seem to have a good way to customize the classpath + // of the compile and test tasks, but since we're not actually publishing any + // Android artifacts from this project (we use this build only for CI testing), we + // can simply copy the dependencies from "privateImplementation" into the standard + // "implementation" for the Android build. + implementation configurations.privateImplementation + androidTestImplementation "junit:junit:4.12" androidTestImplementation "org.hamcrest:hamcrest-library:1.3" androidTestImplementation "com.android.support.test:runner:1.0.2" diff --git a/build-shared.gradle b/build-shared.gradle index 2fcc8ea..085dd5f 100644 --- a/build-shared.gradle +++ b/build-shared.gradle @@ -23,12 +23,30 @@ ext.versions = [ ext.libraries = [:] +configurations { + privateImplementation +} + dependencies { - // Dependencies will not be exposed in the pom - see below in pom.withXml block - implementation "com.google.code.gson:gson:${versions.gson}" - implementation "com.fasterxml.jackson.core:jackson-core:${versions.jacksonCore}" - implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jacksonDatabind}" + // The Gson and Jackson dependencies go into a custom configuration here, instead of + // the regular "implementation" configuration. We don't want them to be visible at + // all in the module's published dependencies - not even in "runtime" scope - because: + // + // 1. For Gson: While java-sdk-common does need Gson in order to work, the + // LaunchDarkly SDKs that use java-sdk-common have different strategies for packaging + // Gson. The Android SDK exposes it as a regular dependency; the Java server-side SDK + // embeds and shades Gson and does not expose it as a dependency. So we are leaving + // it up to the SDK to provide Gson in some way. + // + // 2. For Jackson: The SDKs do not use, require, or embed Jackson; we provide the + // LDJackson class as a convenience for applications that do use Jackson, and it is + // only usable if the application already has Jackson in its classpath. So we do not + // want Jackson to show up as a transitive dependency. + privateImplementation "com.google.code.gson:gson:${versions.gson}" + privateImplementation "com.fasterxml.jackson.core:jackson-core:${versions.jacksonCore}" + privateImplementation "com.fasterxml.jackson.core:jackson-databind:${versions.jacksonDatabind}" + testImplementation configurations.privateImplementation testImplementation "org.hamcrest:hamcrest-library:1.3" testImplementation "junit:junit:4.12" } diff --git a/build.gradle.kts b/build.gradle.kts index fd551d3..04f37af 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -43,11 +43,19 @@ checkstyle { configFile = file("${project.rootDir}/checkstyle.xml") } +tasks.compileJava { + // See note in build-shared.gradle on the purpose of "privateImplementation" + classpath = configurations["privateImplementation"] +} + tasks.javadoc.configure { // Force the Javadoc build to fail if there are any Javadoc warnings. See: https://discuss.gradle.org/t/javadoc-fail-on-warning/18141/3 // See JDK-8200363 (https://bugs.openjdk.java.net/browse/JDK-8200363) // for information about the -Xwerror option. (options as CoreJavadocOptions).addStringOption("Xwerror") + + // See note in build-shared.gradle on the purpose of "privateImplementation" + classpath = configurations["privateImplementation"] } tasks.test.configure { @@ -150,21 +158,6 @@ publishing { url.set("https://github.com/launchdarkly/java-sdk-common") } } - - // We are deliberately hiding our dependencies in the pom, for the following reasons: - // - // 1. Gson: While java-sdk-common does need Gson in order to work, the LaunchDarkly SDKs that use - // java-sdk-common have different strategies for packaging Gson. The Android SDK exposes it as a - // regular dependency; the Java server-side SDK embeds and shades Gson and does not expose it as - // a dependency. So we are leaving it up to the SDK to provide Gson in some way. - // - // 2. Jackson: The SDKs do not use, require, or embed Jackson; we provide the LDJackson class as - // a convenience for applications that do use Jackson. So we do not want it to be a transitive - // dependency. - pom.withXml { - val root = asElement() - root.removeChild(root.getElementsByTagName("dependencies").item(0)) - } } } repositories { From 2a59948dcce9dc894eb84624dcc42f7712b239ff Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 29 Jun 2021 14:52:05 -0700 Subject: [PATCH 52/91] refactor build scripts using buildSrc (#29) --- build-android.gradle | 28 ++-- build-shared.gradle | 52 ------ build.gradle.kts | 153 +++++------------- buildSrc/build.gradle.kts | 20 +++ buildSrc/src/main/kotlin/Dependencies.kt | 67 ++++++++ buildSrc/src/main/kotlin/ProjectValues.kt | 15 ++ .../src/main/kotlin/TestCoverageOverrides.kt | 29 ++++ buildSrc/src/main/kotlin/helpers/Idea.kt | 16 ++ buildSrc/src/main/kotlin/helpers/Jacoco.kt | 52 ++++++ buildSrc/src/main/kotlin/helpers/Javadoc.kt | 21 +++ buildSrc/src/main/kotlin/helpers/Pom.kt | 31 ++++ buildSrc/src/main/kotlin/helpers/Test.kt | 34 ++++ settings.gradle | 1 - settings.gradle.kts | 1 + 14 files changed, 339 insertions(+), 181 deletions(-) delete mode 100644 build-shared.gradle create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/kotlin/Dependencies.kt create mode 100644 buildSrc/src/main/kotlin/ProjectValues.kt create mode 100644 buildSrc/src/main/kotlin/TestCoverageOverrides.kt create mode 100644 buildSrc/src/main/kotlin/helpers/Idea.kt create mode 100644 buildSrc/src/main/kotlin/helpers/Jacoco.kt create mode 100644 buildSrc/src/main/kotlin/helpers/Javadoc.kt create mode 100644 buildSrc/src/main/kotlin/helpers/Pom.kt create mode 100644 buildSrc/src/main/kotlin/helpers/Test.kt delete mode 100644 settings.gradle create mode 100644 settings.gradle.kts diff --git a/build-android.gradle b/build-android.gradle index a7901ec..9c48943 100644 --- a/build-android.gradle +++ b/build-android.gradle @@ -24,7 +24,11 @@ repositories { google() } -apply from: 'build-shared.gradle' +allprojects { + group = ProjectValues.groupId + version = "${version}" // from gradle.properties + archivesBaseName = ProjectValues.artifactId +} android { compileSdkVersion 26 @@ -59,16 +63,14 @@ android { } } -dependencies { - // See note in build-shared.gradle on the purpose of "privateImplementation". The - // Android Gradle plugin doesn't seem to have a good way to customize the classpath - // of the compile and test tasks, but since we're not actually publishing any - // Android artifacts from this project (we use this build only for CI testing), we - // can simply copy the dependencies from "privateImplementation" into the standard - // "implementation" for the Android build. - implementation configurations.privateImplementation - - androidTestImplementation "junit:junit:4.12" - androidTestImplementation "org.hamcrest:hamcrest-library:1.3" - androidTestImplementation "com.android.support.test:runner:1.0.2" +dependencies { // see Dependencies.kt in buildSrc + // See note in Dependencies.kt in buildSrc on the purpose of "privateImplementation". + // The Android Gradle plugin doesn't seem to have a good way to customize the classpath + // of the compile and test tasks, but since we're not actually publishing any Android + // artifacts from this project (we use this build only for CI testing), we can simply + // copy the dependencies from "privateImplementation" into the standard "implementation" + // for the Android build. + Libs.privateImplementation.each { implementation(it) } + Libs.javaTestImplementation.each { testImplementation(it) } + Libs.androidTestImplementation.each { androidTestImplementation(it) } } diff --git a/build-shared.gradle b/build-shared.gradle deleted file mode 100644 index 085dd5f..0000000 --- a/build-shared.gradle +++ /dev/null @@ -1,52 +0,0 @@ - -// These properties are in their own file to ensure that they're kept in sync between the -// main Java build (build.gradle) and the Android CI build (build-android-ci.gradle). - -allprojects { - group = 'com.launchdarkly' - version = "${version}" - archivesBaseName = "launchdarkly-java-sdk-common" - sourceCompatibility = 1.7 - targetCompatibility = 1.7 -} - -ext { - sdkBasePackage = "com.launchdarkly.sdk" - sdkBaseName = "launchdarkly-java-sdk-common" -} - -ext.versions = [ - "gson": "2.7", - "jacksonCore": "2.10.5", - "jacksonDatabind": "2.10.5.1" -] - -ext.libraries = [:] - -configurations { - privateImplementation -} - -dependencies { - // The Gson and Jackson dependencies go into a custom configuration here, instead of - // the regular "implementation" configuration. We don't want them to be visible at - // all in the module's published dependencies - not even in "runtime" scope - because: - // - // 1. For Gson: While java-sdk-common does need Gson in order to work, the - // LaunchDarkly SDKs that use java-sdk-common have different strategies for packaging - // Gson. The Android SDK exposes it as a regular dependency; the Java server-side SDK - // embeds and shades Gson and does not expose it as a dependency. So we are leaving - // it up to the SDK to provide Gson in some way. - // - // 2. For Jackson: The SDKs do not use, require, or embed Jackson; we provide the - // LDJackson class as a convenience for applications that do use Jackson, and it is - // only usable if the application already has Jackson in its classpath. So we do not - // want Jackson to show up as a transitive dependency. - privateImplementation "com.google.code.gson:gson:${versions.gson}" - privateImplementation "com.fasterxml.jackson.core:jackson-core:${versions.jacksonCore}" - privateImplementation "com.fasterxml.jackson.core:jackson-databind:${versions.jacksonDatabind}" - - testImplementation configurations.privateImplementation - testImplementation "org.hamcrest:hamcrest-library:1.3" - testImplementation "junit:junit:4.12" -} diff --git a/build.gradle.kts b/build.gradle.kts index 04f37af..5f6dbb6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,4 @@ import java.time.Duration -import org.gradle.api.tasks.testing.logging.TestExceptionFormat -import org.gradle.external.javadoc.CoreJavadocOptions // These values come from gradle.properties val ossrhUsername: String by project @@ -13,16 +11,9 @@ buildscript { } } -plugins { - java - "java-library" - checkstyle - signing - "maven-publish" - idea - jacoco - id("de.marcphilipp.nexus-publish") version "0.3.0" - id("io.codearte.nexus-staging") version "0.30.0" +plugins { // see Dependencies.kt in buildSrc + Libs.javaBuiltInGradlePlugins.forEach { id(it) } + Libs.javaExtGradlePlugins.forEach { (n, v) -> id(n) version v } } repositories { @@ -32,11 +23,30 @@ repositories { mavenCentral() } -apply { from("build-shared.gradle") } +configurations.all { + // check for updates every build for dependencies with: 'changing: true' + resolutionStrategy.cacheChangingModulesFor(0, "seconds") +} + +base { + group = ProjectValues.groupId + archivesBaseName = ProjectValues.artifactId + version = version +} java { withJavadocJar() withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_1_7 + targetCompatibility = JavaVersion.VERSION_1_7 +} + +// See Dependencies.kt in buildSrc for the purpose of "privateImplementation" +val privateImplementation by configurations.creating + +dependencies { // see Dependencies.kt in buildSrc + Libs.privateImplementation.forEach { privateImplementation(it)} + Libs.javaTestImplementation.forEach { testImplementation(it) } } checkstyle { @@ -44,120 +54,28 @@ checkstyle { } tasks.compileJava { - // See note in build-shared.gradle on the purpose of "privateImplementation" - classpath = configurations["privateImplementation"] -} - -tasks.javadoc.configure { - // Force the Javadoc build to fail if there are any Javadoc warnings. See: https://discuss.gradle.org/t/javadoc-fail-on-warning/18141/3 - // See JDK-8200363 (https://bugs.openjdk.java.net/browse/JDK-8200363) - // for information about the -Xwerror option. - (options as CoreJavadocOptions).addStringOption("Xwerror") - - // See note in build-shared.gradle on the purpose of "privateImplementation" + // See note in Dependencies.kt in buildSrc on "privateImplementation" classpath = configurations["privateImplementation"] } -tasks.test.configure { - testLogging { - events("passed", "skipped", "failed", "standardOut", "standardError") - showStandardStreams = true - exceptionFormat = TestExceptionFormat.FULL - } -} - -tasks.jacocoTestReport.configure { - reports { - xml.isEnabled = true - csv.isEnabled = true - html.isEnabled = true - } -} +helpers.Javadoc.configureTask(tasks.javadoc, configurations["privateImplementation"]) // see Javadoc.kt in buildSrc -tasks.jacocoTestCoverageVerification.configure { - // See notes in CONTRIBUTING.md on code coverage. Unfortunately we can't configure line-by-line code - // coverage overrides within the source code itself, because Jacoco operates on bytecode. - violationRules { - val knownMissedLinesForMethods = mapOf( - "EvaluationReason.error(com.launchdarkly.sdk.EvaluationReason.ErrorKind)" to 1, - "EvaluationReasonTypeAdapter.parse(com.google.gson.stream.JsonReader)" to 1, - "LDValue.equals(java.lang.Object)" to 1, - "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)" to 1, - "json.JsonSerialization.getDeserializableClasses()" to -1, - "json.LDGson.LDTypeAdapter.write(com.google.gson.stream.JsonWriter, java.lang.Object)" to 1, - "json.LDJackson.GsonReaderToJacksonParserAdapter.peekInternal()" to 3 - ) - - knownMissedLinesForMethods.forEach { (signature, maxMissedLines) -> - if (maxMissedLines > 0) { // < 0 means skip entire method - rule { - element = "METHOD" - includes = listOf("com.launchdarkly.sdk." + signature) - limit { - counter = "LINE" - value = "MISSEDCOUNT" - maximum = maxMissedLines.toBigDecimal() - } - } - } - } - - // General rule that we should expect 100% test coverage; exclude any methods that have overrides above - rule { - element = "METHOD" - limit { - counter = "LINE" - value = "MISSEDCOUNT" - maximum = 0.toBigDecimal() - } - excludes = knownMissedLinesForMethods.map { (signature, maxMissedLines) -> - "com.launchdarkly.sdk." + signature } - } - } -} +helpers.Test.configureTask(tasks.compileTestJava, tasks.test, + configurations["privateImplementation"]) // see Test.kt in buildSrc -idea { - module { - isDownloadJavadoc = true - isDownloadSources = true - } -} +helpers.Jacoco.configureTasks( // see Jacoco.kt in buildSrc + tasks.jacocoTestReport, + tasks.jacocoTestCoverageVerification +) -nexusStaging { - packageGroup = "com.launchdarkly" - numberOfRetries = 40 // we've seen extremely long delays in closing repositories -} +helpers.Idea.configure(idea) publishing { publications { create("mavenJava") { from(components["java"]) - groupId = "com.launchdarkly" - artifactId = "launchdarkly-java-sdk-common" - - pom { - name.set("launchdarkly-java-sdk-common") - description.set("LaunchDarkly SDK Java Common Classes") - url.set("https://github.com/launchdarkly/java-sdk-common") - licenses { - license { - name.set("The Apache License, Version 2.0") - url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") - } - } - developers { - developer { - name.set("LaunchDarkly") - email.set("team@launchdarkly.com") - } - } - scm { - connection.set("scm:git:git://github.com/launchdarkly/java-sdk-common.git") - developerConnection.set("scm:git:ssh:git@github.com:launchdarkly/java-sdk-common.git") - url.set("https://github.com/launchdarkly/java-sdk-common") - } - } + helpers.Pom.standardPom(pom) // see Pom.kt in buildSrc } } repositories { @@ -165,6 +83,11 @@ publishing { } } +nexusStaging { + packageGroup = ProjectValues.groupId + numberOfRetries = 40 // we've seen extremely long delays in closing repositories +} + nexusPublishing { clientTimeout.set(Duration.ofMinutes(2)) // we've seen extremely long delays in creating repositories repositories { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..a0a9171 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,20 @@ + +// This build script controls the building of the shared Gradle code in +// buildSrc. Putting code under buildSrc allows us to break it up for better +// clarity, leaving a much simpler build script at the top level of the repo. + +// For the java-sdk-common project, this also allows us to share some values +// between build.gradle.kts and build-android.gradle in a clearer way than +// the old method of including a shared build script. + +// Things that are specific to this project, like dependencies, are in +// buildSrc/src/main/kotlin. Reusable helper code that isn't specific to this +// project is in buildSrc/src/main/kotlin/helpers. + +plugins { + `kotlin-dsl` +} + +repositories { + jcenter() +} diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt new file mode 100644 index 0000000..114181c --- /dev/null +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -0,0 +1,67 @@ + +// Centralize dependencies here instead of writing them out in the top-level +// build script(s). + +object Versions { + const val gson = "2.7" + const val jacksonCore = "2.10.5" + const val jacksonDatabind = "2.10.5.1" +} + +object PluginVersions { + const val nexusPublish = "0.3.0" + const val nexusStaging = "0.30.0" +} + +object Libs { + val implementation = listOf( + // We would put anything here that we want to go into the Gradle "implementation" + // configuration, if and only if we want those things to show up in pom.xml. + ) + + val javaTestImplementation = listOf( + "org.hamcrest:hamcrest-library:1.3", + "junit:junit:4.12" + ) + + val androidTestImplementation = javaTestImplementation + listOf( + "com.android.support.test:runner:1.0.2" + ) + + val privateImplementation = listOf( + // These will be used in the compile-time classpath, but they should *not* be put in + // the usual Gradle "implementation" configuration, because we don't want them to be + // visible at all in the module's published dependencies - not even in "runtime" scope. + // Here's why: + // + // 1. For Gson: While java-sdk-common does need Gson in order to work, the + // LaunchDarkly SDKs that use java-sdk-common have different strategies for packaging + // Gson. The Android SDK exposes it as a regular dependency; the Java server-side SDK + // embeds and shades Gson and does not expose it as a dependency. So we are leaving + // it up to the SDK to provide Gson in some way. + // + // 2. For Jackson: The SDKs do not use, require, or embed Jackson; we provide the + // LDJackson class as a convenience for applications that do use Jackson, and it is + // only usable if the application already has Jackson in its classpath. So we do not + // want Jackson to show up as a transitive dependency. + + "com.google.code.gson:gson:${Versions.gson}", + "com.fasterxml.jackson.core:jackson-core:${Versions.jacksonCore}", + "com.fasterxml.jackson.core:jackson-databind:${Versions.jacksonDatabind}" + ) + + val javaBuiltInGradlePlugins = listOf( + "java", + "java-library", + "checkstyle", + "signing", + "maven-publish", + "idea", + "jacoco" + ) + + val javaExtGradlePlugins = mapOf( + "de.marcphilipp.nexus-publish" to PluginVersions.nexusPublish, + "io.codearte.nexus-staging" to PluginVersions.nexusStaging + ) +} diff --git a/buildSrc/src/main/kotlin/ProjectValues.kt b/buildSrc/src/main/kotlin/ProjectValues.kt new file mode 100644 index 0000000..55fcda8 --- /dev/null +++ b/buildSrc/src/main/kotlin/ProjectValues.kt @@ -0,0 +1,15 @@ + +// This file defines basic properties of the project that are used in the +// build script and the helper code. + +object ProjectValues { + const val groupId = "com.launchdarkly" + const val artifactId = "launchdarkly-java-sdk-common" + const val description = "LaunchDarkly SDK Java Common Classes" + const val githubRepo = "launchdarkly/java-sdk-common" + + const val sdkBasePackage = "com.launchdarkly.sdk" + + const val pomDeveloperName = "LaunchDarkly SDK Team" + const val pomDeveloperEmail = "sdks@launchdarkly.com" +} diff --git a/buildSrc/src/main/kotlin/TestCoverageOverrides.kt b/buildSrc/src/main/kotlin/TestCoverageOverrides.kt new file mode 100644 index 0000000..8014382 --- /dev/null +++ b/buildSrc/src/main/kotlin/TestCoverageOverrides.kt @@ -0,0 +1,29 @@ + +// See notes in CONTRIBUTING.md on code coverage. Unfortunately we can't configure +// line-by-line code coverage overrides within the source code itself, because Jacoco +// operates on bytecode. + +// These values are used by helpers/Jacoco.kt. + +object TestCoverageOverrides { + val prefixForAllMethodSignatures = ProjectValues.sdkBasePackage + "." + + // Each entry in methodsWithMissedLineCount is an override to tell the Jacoco plugin + // that we're aware of a gap in our test coverage and are OK with it. In each entry, + // the key is the method signature and the value is the number of lines that we + // expect Jacoco to report as missed. + val methodsWithMissedLineCount = mapOf( + "EvaluationReason.error(com.launchdarkly.sdk.EvaluationReason.ErrorKind)" to 1, + "EvaluationReasonTypeAdapter.parse(com.google.gson.stream.JsonReader)" to 1, + "LDValue.equals(java.lang.Object)" to 1, + "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)" to 1, + "json.LDGson.LDTypeAdapter.write(com.google.gson.stream.JsonWriter, java.lang.Object)" to 1, + "json.LDJackson.GsonReaderToJacksonParserAdapter.peekInternal()" to 3 + ).mapKeys { prefixForAllMethodSignatures + it.key } + + // Each entry in methodsToSkip is an override to tell the Jacoco plugin to ignore + // code coverage in the method with the specified signature. + val methodsToSkip = listOf( + "json.JsonSerialization.getDeserializableClasses()" + ).map { prefixForAllMethodSignatures + it } +} diff --git a/buildSrc/src/main/kotlin/helpers/Idea.kt b/buildSrc/src/main/kotlin/helpers/Idea.kt new file mode 100644 index 0000000..c2f85d5 --- /dev/null +++ b/buildSrc/src/main/kotlin/helpers/Idea.kt @@ -0,0 +1,16 @@ +package helpers + +import org.gradle.api.tasks.TaskProvider +import org.gradle.plugins.ide.idea.model.IdeaModel + +// Idea.configure provides reusable configuration logic for the Idea +// behavior we normally use. + +object Idea { + fun configure(ideaModel: IdeaModel) { + ideaModel.module { + isDownloadJavadoc = true + isDownloadSources = true + } + } +} diff --git a/buildSrc/src/main/kotlin/helpers/Jacoco.kt b/buildSrc/src/main/kotlin/helpers/Jacoco.kt new file mode 100644 index 0000000..77f6c87 --- /dev/null +++ b/buildSrc/src/main/kotlin/helpers/Jacoco.kt @@ -0,0 +1,52 @@ +package helpers + +import org.gradle.api.tasks.TaskProvider +import org.gradle.testing.jacoco.tasks.JacocoReport +import org.gradle.testing.jacoco.tasks.JacocoCoverageVerification + +// Jacoco.configureTasks provides reusable configuration logic for using the Jacoco +// test coverage plugin in a Java project. See also: TestCoverageOverrides.kt + +object Jacoco { + fun configureTasks(reportTask: TaskProvider, + verificationTask: TaskProvider) { + reportTask.configure { + reports { + xml.isEnabled = true + csv.isEnabled = true + html.isEnabled = true + } + } + + verificationTask.configure { + // See notes in CONTRIBUTING.md on code coverage. Unfortunately we can't configure line-by-line code + // coverage overrides within the source code itself, because Jacoco operates on bytecode. + violationRules { + TestCoverageOverrides.methodsWithMissedLineCount.forEach { signature, maxMissedLines -> + rule { + element = "METHOD" + includes = listOf(signature) + limit { + counter = "LINE" + value = "MISSEDCOUNT" + maximum = maxMissedLines.toBigDecimal() + } + } + } + + // General rule that we should expect 100% test coverage; exclude any methods that + // have overrides in TestCoverageOverrides. + rule { + element = "METHOD" + limit { + counter = "LINE" + value = "MISSEDCOUNT" + maximum = 0.toBigDecimal() + } + excludes = TestCoverageOverrides.methodsWithMissedLineCount.map { it.key } + + TestCoverageOverrides.methodsToSkip + } + } + } + } +} diff --git a/buildSrc/src/main/kotlin/helpers/Javadoc.kt b/buildSrc/src/main/kotlin/helpers/Javadoc.kt new file mode 100644 index 0000000..2549ebc --- /dev/null +++ b/buildSrc/src/main/kotlin/helpers/Javadoc.kt @@ -0,0 +1,21 @@ +package helpers + +import org.gradle.api.artifacts.Configuration +import org.gradle.api.tasks.TaskProvider +import org.gradle.api.tasks.javadoc.Javadoc +import org.gradle.external.javadoc.CoreJavadocOptions + +object Javadoc { + fun configureTask(javadocTask: TaskProvider, classpathConfig: Configuration?) { + javadocTask.configure { + // Force the Javadoc build to fail if there are any Javadoc warnings. See: https://discuss.gradle.org/t/javadoc-fail-on-warning/18141/3 + // See JDK-8200363 (https://bugs.openjdk.java.net/browse/JDK-8200363) + // for information about the -Xwerror option. + (options as CoreJavadocOptions).addStringOption("Xwerror") + + if (classpathConfig != null) { + classpath += classpathConfig + } + } + } +} diff --git a/buildSrc/src/main/kotlin/helpers/Pom.kt b/buildSrc/src/main/kotlin/helpers/Pom.kt new file mode 100644 index 0000000..ac9906e --- /dev/null +++ b/buildSrc/src/main/kotlin/helpers/Pom.kt @@ -0,0 +1,31 @@ +package helpers + +import org.gradle.api.publish.maven.MavenPom + +// Pom.standardPom provides reusable logic for setting the pom.xml properties +// of LaunchDarkly packages. It gets its values from ProjectValues.kt. + +object Pom { + fun standardPom(pom: MavenPom) { + pom.name.set(ProjectValues.artifactId) + pom.description.set(ProjectValues.description) + pom.url.set("https://github.com/${ProjectValues.githubRepo}") + pom.licenses { + license { + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + name.set("The Apache License, Version 2.0") + } + } + pom.developers { + developer { + name.set(ProjectValues.pomDeveloperName) + email.set(ProjectValues.pomDeveloperEmail) + } + } + pom.scm { + connection.set("scm:git:git://github.com/${ProjectValues.githubRepo}.git") + developerConnection.set("scm:git:ssh:git@github.com:${ProjectValues.githubRepo}.git") + url.set("https://github.com/${ProjectValues.githubRepo}") + } + } +} diff --git a/buildSrc/src/main/kotlin/helpers/Test.kt b/buildSrc/src/main/kotlin/helpers/Test.kt new file mode 100644 index 0000000..c213db5 --- /dev/null +++ b/buildSrc/src/main/kotlin/helpers/Test.kt @@ -0,0 +1,34 @@ +package helpers + +import org.gradle.api.artifacts.Configuration +import org.gradle.api.tasks.TaskProvider +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.api.tasks.testing.Test +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + +// Test.configureTask provides reusable configuration logic for the Java test +// behavior we normally use. + +object Test { + fun configureTask(compileTestTask: TaskProvider, testTask: TaskProvider, + classpathConfig: Configuration?) { + + compileTestTask.configure { + if (classpathConfig != null) { + classpath += classpathConfig + } + } + + testTask.configure { + testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + exceptionFormat = TestExceptionFormat.FULL + } + + if (classpathConfig != null) { + classpath += classpathConfig + } + } + } +} diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index a9e03eb..0000000 --- a/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'launchdarkly-java-sdk-common' diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..fbd2f48 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "launchdarkly-java-sdk-common" From 03adb77d9486e227222450f938d1826ec654a961 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 14 Sep 2021 16:25:30 -0700 Subject: [PATCH 53/91] use Releaser v2 config format + newer CircleCI images (#33) --- .circleci/config.yml | 16 +++------------- .ldrelease/config.yml | 11 ++++++++--- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7961a08..ce5855b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,22 +10,12 @@ workflows: - build-linux - test-linux: name: Java 8 - Linux - OpenJDK - docker-image: circleci/openjdk:8 - requires: - - build-linux - - test-linux: - name: Java 9 - Linux - OpenJDK - docker-image: circleci/openjdk:9 - requires: - - build-linux - - test-linux: - name: Java 10 - Linux - OpenJDK - docker-image: circleci/openjdk:10 + docker-image: cimg/openjdk:8.0 requires: - build-linux - test-linux: name: Java 11 - Linux - OpenJDK - docker-image: circleci/openjdk:11 + docker-image: cimg/openjdk:11.0 with-coverage: true requires: - build-linux @@ -37,7 +27,7 @@ workflows: jobs: build-linux: docker: - - image: circleci/openjdk:8u131-jdk # To match the version pre-installed in Ubuntu 16 and used by Jenkins for releasing + - image: cimg/openjdk:8.0 steps: - checkout - run: cp gradle.properties.example gradle.properties diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index c1ca1e8..93719a2 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -1,3 +1,5 @@ +version: 2 + repo: public: java-sdk-common private: java-sdk-common-private @@ -8,8 +10,11 @@ publications: - url: https://javadoc.io/doc/com.launchdarkly/launchdarkly-java-sdk-common description: documentation (javadoc.io) -template: - name: gradle +jobs: + - docker: + image: gradle:6.8.3-jdk11 + template: + name: gradle documentation: - githubPages: true + gitHubPages: true From 979d0de0fb250e46863f47bfaa9140eeacd03377 Mon Sep 17 00:00:00 2001 From: Ember Stevens Date: Sun, 26 Sep 2021 16:49:04 -0700 Subject: [PATCH 54/91] Updates docs URLs --- CONTRIBUTING.md | 2 +- README.md | 2 +- .../java/com/launchdarkly/sdk/LDUser.java | 26 +++++++++---------- .../com/launchdarkly/sdk/UserAttribute.java | 4 +-- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be0f1ab..c908b04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to the LaunchDarkly SDK Java Common Code -LaunchDarkly has published an [SDK contributor's guide](https://docs.launchdarkly.com/docs/sdk-contributors-guide) that provides a detailed explanation of how our SDKs work. See below for additional information on how to contribute to this project. +LaunchDarkly has published an [SDK contributor's guide](https://docs.launchdarkly.com/sdk/concepts/contributors-guide) that provides a detailed explanation of how our SDKs work. See below for additional information on how to contribute to this project. ## Submitting bug reports and feature requests diff --git a/README.md b/README.md index 5c29584..5821bc4 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ See [Contributing](https://github.com/launchdarkly/dotnet-sdk-common/blob/master * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. -* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/docs) for a complete list. +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Read [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. * Explore LaunchDarkly * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index 11d7942..234f12b 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -23,8 +23,8 @@ * optional. You may also define custom properties with arbitrary names and values. *

* For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference - * guides on Setting user attributes - * and Targeting users. + * guides on Setting user attributes + * and Targeting users. *

* LaunchDarkly defines a standard JSON encoding for user objects, used by the JavaScript SDK and also in analytics * events. {@link LDUser} can be converted to and from JSON in any of these ways: @@ -340,7 +340,7 @@ public Builder privateIp(String s) { /** * Sets the secondary key for a user. This affects - * feature flag targeting + * 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 @@ -516,7 +516,7 @@ public Builder privateEmail(String email) { /** * Adds a {@link java.lang.String}-valued custom attribute. When set to one of the - * built-in + * built-in * user attribute keys, this custom attribute will be ignored. * * @param k the key for the custom attribute @@ -529,7 +529,7 @@ public Builder custom(String k, String v) { /** * Adds an integer-valued custom attribute. When set to one of the - * built-in + * built-in * user attribute keys, this custom attribute will be ignored. * * @param k the key for the custom attribute @@ -542,7 +542,7 @@ public Builder custom(String k, int n) { /** * Adds a double-precision numeric custom attribute. When set to one of the - * built-in + * built-in * user attribute keys, this custom attribute will be ignored. * * @param k the key for the custom attribute @@ -555,7 +555,7 @@ public Builder custom(String k, double n) { /** * Add a boolean-valued custom attribute. When set to one of the - * built-in + * built-in * user attribute keys, this custom attribute will be ignored. * * @param k the key for the custom attribute @@ -568,7 +568,7 @@ public Builder custom(String k, boolean b) { /** * Add a custom attribute whose value can be any JSON type, using {@link LDValue}. When set to one of the - * built-in + * built-in * user attribute keys, this custom attribute will be ignored. * * @param k the key for the custom attribute @@ -593,7 +593,7 @@ private Builder customInternal(UserAttribute a, LDValue v) { /** * 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 + * built-in * user attribute keys, this custom attribute will be ignored. * * @param k the key for the custom attribute @@ -607,7 +607,7 @@ public Builder privateCustom(String k, String v) { /** * Add an int-valued custom attribute that will not be sent back to LaunchDarkly. * When set to one of the - * built-in + * built-in * user attribute keys, this custom attribute will be ignored. * * @param k the key for the custom attribute @@ -621,7 +621,7 @@ public Builder privateCustom(String k, int n) { /** * Add a double-precision numeric custom attribute that will not be sent back to LaunchDarkly. * When set to one of the - * built-in + * built-in * user attribute keys, this custom attribute will be ignored. * * @param k the key for the custom attribute @@ -635,7 +635,7 @@ public Builder privateCustom(String k, double n) { /** * Add a boolean-valued custom attribute that will not be sent back to LaunchDarkly. * When set to one of the - * built-in + * built-in * user attribute keys, this custom attribute will be ignored. * * @param k the key for the custom attribute @@ -649,7 +649,7 @@ public Builder privateCustom(String k, boolean 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 + * built-in * user attribute keys, this custom attribute will be ignored. * * @param k the key for the custom attribute diff --git a/src/main/java/com/launchdarkly/sdk/UserAttribute.java b/src/main/java/com/launchdarkly/sdk/UserAttribute.java index 6514851..f8fb8ec 100644 --- a/src/main/java/com/launchdarkly/sdk/UserAttribute.java +++ b/src/main/java/com/launchdarkly/sdk/UserAttribute.java @@ -18,8 +18,8 @@ * always reuse the same instances. *

* For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference - * guides on Setting user attributes - * and Targeting users. + * guides on Setting user attributes + * and Targeting users. */ @JsonAdapter(UserAttribute.UserAttributeTypeAdapter.class) public final class UserAttribute implements JsonSerializable { From 056ff108a9c72443211c67d34db86a35301e1c1c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 30 Nov 2021 10:29:24 -0800 Subject: [PATCH 55/91] update Gson to 2.8.9 --- buildSrc/src/main/kotlin/Dependencies.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 114181c..153a00d 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -3,7 +3,7 @@ // build script(s). object Versions { - const val gson = "2.7" + const val gson = "2.8.9" const val jacksonCore = "2.10.5" const val jacksonDatabind = "2.10.5.1" } From 30213bcfeddad5ead7ddbbd262b3794fd321c844 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Jan 2022 17:37:42 -0800 Subject: [PATCH 56/91] use Gradle 7 --- gradle/wrapper/gradle-wrapper.jar | Bin 58695 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 2 ++ gradlew.bat | 22 ++++------------------ 4 files changed, 7 insertions(+), 19 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index f3d88b1c2faf2fc91d853cd5d4242b5547257070..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f 100644 GIT binary patch delta 12842 zcmY+q1ymhDmoSOmQr324hm`&0SZbS3R18r37J8Z0F-T>@gIK` zatajf2b1lO#&E_RnGDF51uk%8Wcxm3`%Yhe7Psx?*?eZ?8M9_+G(?L=s^OG1n#S(NP3gF?2Mr2^f5E7sM~iVC`Rn;(-^MZ zZu*ZXB;XmgvPls(e#)MMTObsEx9oNz-K?AmQ8pP&P7vqx*=5zxjU+ye_1R<%KSg1? z7H&Yh))(Ke!Pa+aVuWxPKa_~Qo_IH}*;tV8n~O*Xa?t3P^9=L%=wOL1=~{LVv}mU8Q#e6s>v}iV8cDP|EdY)`dp≶7^21 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

XiM^r_M1uFTw)(UPqqUJ zed7DJQvOpplYJoNl`A3a2Zr~v)Y}K|*%d7?8;dC*Af}0=(EXrk7hP8PM4v)ZawEv0 z5j5q_6vilv4*ppZ3Wke7%8b`odJu26#YseA?$sP8?@d?TpACTX`G^?%K=Hly9Y$Tj z5`i!}-WH0lQ3yt-&Pf&Z)OxjKfAZ3X`YI2)5o@9EiOA}?kX`^rk;yaOh^Li4VHcT& zd6ztJz^`H!8>cL)a%?Lnofzo0$WPrgkNcP#u@{%~EvA7g#go z+Q4$Q^o_MJTE0G_&9@cgp)WF>2zo>AG}C|(~E^_?ijc2EQ>> zFH1Lxc@i!gDHxvEsrfc+Kiz3Q6Z*NM+U6DH6*-Fv{YDY4>U&eegGH~Xmk~!sd6fw2 z!6^4(MZWhzf(xs6BHy=oS+dir0_JW(j*AIu$9&&p@}T;&6s7(yYidEdkp6pk9}Z(F zk6lG`d!HcJWP{7X)&P5FW;XWU6;zke2e+g*#1kW4L0Auj5pVA45Bhzt{8lj)XyMHu z0p;Q}t*NLnXbGQOTC9WdOQsX;_yLThOP$mzcEbqSBifmDecV;Fqhtm#KxfJTMhY!K zw{1L9za2QtuIWXu0ZYm@Uy72(9^vYajuP$(VAKn+&Jp z!%S;#BqO;02)n~6pFYK8oRC6wXx@kyb?VENM7l?NFg)%^=q4aZ* zwVCZxA8lalF&n`vk#gxD@!9~Aktc+h2UUUax3q2XKQLuL@CpVEpx2IyiH3bALY+Oi zDydtacE1Z|$z5qsCG=%F{}8_ozwieWuM4VcDesuu+wX&YjHm^Ryx)pVtiSLpch7<` zIw9QM%I;(VG9~#Rw2JNt+;?_=O2lA#YSudpgsM`e&(}1 zGdj7U)St4`+Cj#Vn>a;Bj~Q3S1G54;gzq)`b_Hh~ip(`BK#GMkI*RUcK+6ycjl?QN zP%sH5*R$$6A&8j8O4iFJhuHMXLJm|drH+=!_{`60&+tC8*m3Z=YsR@~%eaZyWQI|W zie7;BRL6`LR+4B{%FY~;-^*1GGTCLp!U6VoS36GUAWv$Rcc#|a7DD01{QPB=VyT)? z+1ZV>P=bP1;>v-+j0;BwenO`EKcIbmB3$r*@a~=?5#KB_R$3bTg zOjEutZmWuylIJQEM?28D!4uA#0CZEJHar0TFAW|NwP;WL|EIb_L2>;}e*N!K9E61S zH&u@m!n#CH{C_j}{#ybCRU8z7`Cs{b>@bxSkp3lOm`qAYgB>npnw%V>w}t_+S_Z)s zFhGKq^e%m@=^@sD0CJdAK=`Upbr}?}N zf;snqpt#dOwhxFg82(%Tw=ND+@`O0JGeOWd7-3R8A%Yu%FhiaYXD>p?t2+o%_1CKE z{g(>=g%}X(O%M!}KZK%$7-F<34wD-24`y$WLDv6z?ty=_b)Oi*x&?v}3j0f`AV3HL zWPAYw!XNs-1EmZ9=d=$6LAJISVJ4&gQM5=bh{!f0OmFNz8oMnGgOl_RK5VPP3@87C zpLS$muCo5YTmOslSjFLb`AXIJ@1|O{pm5r z9MxUb-8HMe*|TpFa%dE?+7}MVQ-A-N8wvtq85ROU2%KYt4etC52X0%W08haQgGSU| zvw=K$djTYSLy@4My_Te_8JcZp8Oozg{-ey>*Nm7BkGrX(M~HU6N16U>DSj<`;cy`u zR?0B23zx|*o1V=#fLZ=wd6*NfWjC`pqA^mt>DTZjGBeX`0ZK>Qgxz+37Dyb#NT4WT zl@7Lmh{WdY*h%e_bo6(oXKw=`(9=mft10fOR4< zCOUungtJZPAM~qw4Ydy9PhvYTi1ZRqR5nY)?OX2O3FJ6VXy&`&H%^U|d>N>@f zQ}Cd;DV}_bNiVTW8HcUJDRvXCUwGx|(r2-KbXY>jpd!~jmt(

p*eWM!w;MI3hb2}DlKZV3$r+FsPh`1u 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 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -54,7 +54,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -64,28 +64,14 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell From 5ef951cff23b3ca962bf0abb22b8159a36128377 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Jan 2022 17:44:49 -0800 Subject: [PATCH 57/91] don't use jcenter --- build-android.gradle | 1 - buildSrc/build.gradle.kts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/build-android.gradle b/build-android.gradle index 9c48943..0329994 100644 --- a/build-android.gradle +++ b/build-android.gradle @@ -6,7 +6,6 @@ buildscript { mavenCentral() mavenLocal() google() - jcenter() } dependencies { classpath 'com.android.tools.build:gradle:4.2.0' diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index a0a9171..8fe36d0 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -16,5 +16,5 @@ plugins { } repositories { - jcenter() + gradlePluginPortal() } From 913f2efc1aa0cff4f2a1bc4f0cef6e73745c34a4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Jan 2022 17:50:43 -0800 Subject: [PATCH 58/91] add Java 17 to CI --- .circleci/config.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index ce5855b..10ead3e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -16,6 +16,11 @@ workflows: - test-linux: name: Java 11 - Linux - OpenJDK docker-image: cimg/openjdk:11.0 + requires: + - build-linux + - test-linux: + name: Java 17 - Linux - OpenJDK + docker-image: cimg/openjdk:17.0 with-coverage: true requires: - build-linux From e8a5373e7c2af1212fbe739bb9eff636949c9cff Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Jan 2022 19:30:58 -0800 Subject: [PATCH 59/91] also test Java 17 in Windows --- .circleci/config.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 10ead3e..2cb624d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -26,6 +26,10 @@ workflows: - build-linux - build-test-windows: name: Java 11 - Windows - OpenJDK + openjdk-version: 11.0.2.01 + - build-test-windows: + name: Java 17 - Windows - OpenJDK + openjdk-version: 17.0.1 - build-test-android: name: Android @@ -91,17 +95,20 @@ jobs: path: coverage build-test-windows: + parameters: + openjdk-version: + type: string executor: name: win/vs2019 shell: powershell.exe steps: - checkout + - run: + name: uninstall previous openjdk + command: choco uninstall openjdk - run: name: install OpenJDK - command: | - $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' + command: choco install openjdk --version <> - run: name: build and test command: | From c589a19b899731ca3bd2cadc02d9987f6a64ee3e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 24 Jan 2022 15:21:42 -0800 Subject: [PATCH 60/91] don't suppress null property values when we serialize with Gson --- src/main/java/com/launchdarkly/sdk/LDValue.java | 4 +--- .../java/com/launchdarkly/sdk/json/JsonSerialization.java | 6 +++++- .../java/com/launchdarkly/sdk/json/JsonTestHelpers.java | 4 ++-- src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java | 2 +- .../launchdarkly/sdk/json/LDValueJsonSerializationTest.java | 1 + 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index 956dc7f..bf36572 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -45,8 +45,6 @@ */ @JsonAdapter(LDValueTypeAdapter.class) public abstract class LDValue implements JsonSerializable { - static final Gson gson = new Gson(); - /** * Returns the same value if non-null, or {@link #ofNull()} if null. * @@ -393,7 +391,7 @@ public LDValue get(String name) { * @return a JSON string */ public String toJsonString() { - return gson.toJson(this); + return JsonSerialization.serialize(this); } abstract void write(JsonWriter writer) throws IOException; diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index 8cadeb2..034717c 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.json; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; @@ -35,7 +36,10 @@ private JsonSerialization() {} static final List> knownDeserializableClasses = new ArrayList<>(); - private static final Gson gson = new Gson(); + // This Gson instance has serializeNulls enabled because we want the decision of whether to include + // a null property value to be left up to our own serializers. The default behavior would mean that + // the GsonWriter would not allow us to write a null property value ever. + private static final Gson gson = new GsonBuilder().serializeNulls().create(); /** * Converts an object to its JSON representation. diff --git a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java index f80546e..667dc54 100644 --- a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java @@ -15,7 +15,7 @@ @SuppressWarnings("javadoc") public abstract class JsonTestHelpers extends BaseTest { - static final Gson gson = new Gson(); + static final Gson gson = new GsonBuilder().serializeNulls().create(); // Note that when we verify the behavior of Gson with LDGson in this project's unit tests, that // is not an adequate test for whether the adapters will work in the Java SDK where there is the @@ -24,7 +24,7 @@ public abstract class JsonTestHelpers extends BaseTest { // that the adapters work correctly if Gson actually uses them. public static Gson configureGson() { - return new GsonBuilder().registerTypeAdapterFactory(LDGson.typeAdapters()).create(); + return new GsonBuilder().serializeNulls().registerTypeAdapterFactory(LDGson.typeAdapters()).create(); } public static ObjectMapper configureJacksonMapper() { diff --git a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java index d493d8e..5f03611 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java @@ -63,7 +63,7 @@ public void valueMapToJsonElementMap() { public void complexObjectToJsonTree() { LDUser user = new LDUser.Builder("userkey").name("name") .custom("attr1", LDValue.ofNull()) - .custom("atrt2", LDValue.of(true)) + .custom("attr2", LDValue.of(true)) .custom("attr3", LDValue.of(false)) .custom("attr4", LDValue.of(0)) .custom("attr5", LDValue.of(1)) diff --git a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java index 04cd551..8e443d7 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java @@ -31,6 +31,7 @@ public void jsonEncodingForNonNullValues() throws Exception { verifyValueSerialization(LDValue.of(2.5d), "2.5"); verifyValueSerialization(JsonTestHelpers.basicArrayValue(), "[2,\"x\"]"); verifyValueSerialization(JsonTestHelpers.basicObjectValue(), "{\"x\":2}"); + verifyValueSerialization(LDValue.buildObject().put("x", LDValue.ofNull()).build(), "{\"x\":null}"); verifyDeserializeInvalidJson(LDValue.class, "]"); } From d3b23694d2db13e334865f3f29da88be990308df Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 25 Jan 2022 12:17:09 -0800 Subject: [PATCH 61/91] remove obsolete publish-docs script --- .ldrelease/publish-docs.sh | 7 ------- 1 file changed, 7 deletions(-) delete mode 100755 .ldrelease/publish-docs.sh diff --git a/.ldrelease/publish-docs.sh b/.ldrelease/publish-docs.sh deleted file mode 100755 index 81e1bb4..0000000 --- a/.ldrelease/publish-docs.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -set -ue - -# Publish to Github Pages -echo "Publishing to Github Pages" -./gradlew gitPublishPush From 9a05d5a7b957fb0d67ebec3e1419197a1b87fe65 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 28 Jan 2022 15:56:24 -0600 Subject: [PATCH 62/91] Merge feature branch big-segments for 1.3.0 release (#42) --- .../launchdarkly/sdk/EvaluationReason.java | 74 ++++++++++++++++--- .../sdk/EvaluationReasonTypeAdapter.java | 32 ++++++-- .../sdk/EvaluationReasonTest.java | 32 ++++++-- ...EvaluationReasonJsonSerializationTest.java | 16 ++-- 4 files changed, 129 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java index 47bfbac..c93517a 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -95,6 +95,32 @@ public static enum ErrorKind { */ EXCEPTION } + + /** + * Enumerated type defining the possible values of {@link #getBigSegmentsStatus()}. + */ + public static enum BigSegmentsStatus { + /** + * Indicates that the Big Segment query involved in the flag evaluation was successful, and that + * the segment state is considered up to date. + */ + HEALTHY, + /** + * Indicates that the Big Segment query involved in the flag evaluation was successful, but that + * the segment state may not be up to date. + */ + STALE, + /** + * Indicates that Big Segments could not be queried for the flag evaluation because the SDK + * configuration did not include a Big Segment store. + */ + NOT_CONFIGURED, + /** + * Indicates that the Big Segment query involved in the flag evaluation failed, for instance due + * to a database error. + */ + STORE_ERROR + } // static instances to avoid repeatedly allocating reasons for the same parameters private static final EvaluationReason OFF_INSTANCE = new EvaluationReason(Kind.OFF); @@ -115,9 +141,10 @@ public static enum ErrorKind { private final boolean inExperiment; private final ErrorKind errorKind; private final Exception exception; + private final BigSegmentsStatus bigSegmentsStatus; private EvaluationReason(Kind kind, int ruleIndex, String ruleId, String prerequisiteKey, boolean inExperiment, - ErrorKind errorKind, Exception exception) { + ErrorKind errorKind, Exception exception, BigSegmentsStatus bigSegmentsStatus) { this.kind = kind; this.ruleIndex = ruleIndex; this.ruleId = ruleId; @@ -125,18 +152,19 @@ private EvaluationReason(Kind kind, int ruleIndex, String ruleId, String prerequ this.inExperiment = inExperiment; this.errorKind = errorKind; this.exception = exception; + this.bigSegmentsStatus = bigSegmentsStatus; } private EvaluationReason(Kind kind) { - this(kind, -1, null, null, NOT_IN_EXPERIMENT, null, null); + this(kind, -1, null, null, NOT_IN_EXPERIMENT, null, null, null); } private EvaluationReason(Kind kind, boolean inExperiment) { - this(kind, -1, null, null, inExperiment, null, null); + this(kind, -1, null, null, inExperiment, null, null, null); } private EvaluationReason(ErrorKind errorKind, Exception exception) { - this(Kind.ERROR, -1, null, null, NOT_IN_EXPERIMENT, errorKind, exception); + this(Kind.ERROR, -1, null, null, NOT_IN_EXPERIMENT, errorKind, exception, null); } /** @@ -215,7 +243,33 @@ public ErrorKind getErrorKind() { public Exception getException() { return exception; } - + + /** + * Describes the validity of Big Segment information, if and only if the flag evaluation required + * querying at least one Big Segment. Otherwise it returns {@code null}. + *

+ * Big Segments are a specific type of user segments. For more information, read the + * LaunchDarkly documentation + * . + * + * @return the {@link BigSegmentsStatus} from the evaluation or {@code null} + */ + public BigSegmentsStatus getBigSegmentsStatus() { + return bigSegmentsStatus; + } + + /** + * Returns a copy of this {@link EvaluationReason} with a specific {@link BigSegmentsStatus} + * value. + * + * @param bigSegmentsStatus the new property value + * @return a new reason object + */ + public EvaluationReason withBigSegmentsStatus(BigSegmentsStatus bigSegmentsStatus) { + return new EvaluationReason(kind, ruleIndex, ruleId, prerequisiteKey, inExperiment, errorKind, + exception, bigSegmentsStatus); + } + /** * Returns a simple string representation of the reason. *

@@ -250,14 +304,16 @@ public boolean equals(Object other) { Objects.equals(prerequisiteKey, o.prerequisiteKey) && inExperiment == o.inExperiment && Objects.equals(errorKind, o.errorKind) && - Objects.equals(exception, o.exception); + Objects.equals(exception, o.exception) && + Objects.equals(bigSegmentsStatus, o.bigSegmentsStatus); } return false; } @Override public int hashCode() { - return Objects.hash(kind, ruleIndex, ruleId, prerequisiteKey, inExperiment, errorKind, exception); + return Objects.hash(kind, ruleIndex, ruleId, prerequisiteKey, inExperiment, errorKind, + exception, bigSegmentsStatus); } /** @@ -321,7 +377,7 @@ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId) { * @return a reason object */ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId, boolean inExperiment) { - return new EvaluationReason(Kind.RULE_MATCH, ruleIndex, ruleId, null, inExperiment, null, null); + return new EvaluationReason(Kind.RULE_MATCH, ruleIndex, ruleId, null, inExperiment, null, null, null); } /** @@ -331,7 +387,7 @@ public static EvaluationReason ruleMatch(int ruleIndex, String ruleId, boolean i * @return a reason object */ public static EvaluationReason prerequisiteFailed(String prerequisiteKey) { - return new EvaluationReason(Kind.PREREQUISITE_FAILED, -1, null, prerequisiteKey, NOT_IN_EXPERIMENT, null, null); + return new EvaluationReason(Kind.PREREQUISITE_FAILED, -1, null, prerequisiteKey, NOT_IN_EXPERIMENT, null, null, null); } /** diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java index 122f72f..98ec00a 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java @@ -24,6 +24,7 @@ static EvaluationReason parse(JsonReader reader) throws IOException { String prereqKey = null; boolean inExperiment = false; EvaluationReason.ErrorKind errorKind = null; + EvaluationReason.BigSegmentsStatus bigSegmentsStatus = null; reader.beginObject(); while (reader.peek() != JsonToken.END_OBJECT) { @@ -47,6 +48,9 @@ static EvaluationReason parse(JsonReader reader) throws IOException { case "errorKind": errorKind = readEnum(EvaluationReason.ErrorKind.class, reader); break; + case "bigSegmentsStatus": + bigSegmentsStatus = readEnum(EvaluationReason.BigSegmentsStatus.class, reader); + break; default: reader.skipValue(); // ignore any unexpected property } @@ -56,23 +60,34 @@ static EvaluationReason parse(JsonReader reader) throws IOException { if (kind == null) { throw new JsonParseException("EvaluationReason missing required property \"kind\""); } + EvaluationReason reason; switch (kind) { case OFF: - return EvaluationReason.off(); + reason = EvaluationReason.off(); + break; case FALLTHROUGH: - return EvaluationReason.fallthrough(inExperiment); + reason = EvaluationReason.fallthrough(inExperiment); + break; case TARGET_MATCH: - return EvaluationReason.targetMatch(); + reason = EvaluationReason.targetMatch(); + break; case RULE_MATCH: - return EvaluationReason.ruleMatch(ruleIndex, ruleId, inExperiment); + reason = EvaluationReason.ruleMatch(ruleIndex, ruleId, inExperiment); + break; case PREREQUISITE_FAILED: - return EvaluationReason.prerequisiteFailed(prereqKey); + reason = EvaluationReason.prerequisiteFailed(prereqKey); + break; case ERROR: - return EvaluationReason.error(errorKind); + reason = EvaluationReason.error(errorKind); + break; default: // COVERAGE: compiler requires default but there are no other values return null; } + if (bigSegmentsStatus != null) { + return reason.withBigSegmentsStatus(bigSegmentsStatus); + } + return reason; } @Override @@ -114,6 +129,11 @@ public void write(JsonWriter writer, EvaluationReason reason) throws IOException default: break; } + + if (reason.getBigSegmentsStatus() != null) { + writer.name("bigSegmentsStatus"); + writer.value(reason.getBigSegmentsStatus().name()); + } writer.endObject(); } diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java index 5311f9f..d7bd0a1 100644 --- a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java @@ -1,9 +1,7 @@ package com.launchdarkly.sdk; -import org.junit.Test; - -import java.util.List; - +import static com.launchdarkly.sdk.EvaluationReason.BigSegmentsStatus.HEALTHY; +import static com.launchdarkly.sdk.EvaluationReason.BigSegmentsStatus.STALE; import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.CLIENT_NOT_READY; import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.FLAG_NOT_FOUND; import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.WRONG_TYPE; @@ -13,10 +11,14 @@ import static com.launchdarkly.sdk.EvaluationReason.Kind.PREREQUISITE_FAILED; import static com.launchdarkly.sdk.EvaluationReason.Kind.RULE_MATCH; import static com.launchdarkly.sdk.EvaluationReason.Kind.TARGET_MATCH; -import static java.util.Arrays.asList; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static java.util.Arrays.asList; + +import org.junit.Test; + +import java.util.List; @SuppressWarnings("javadoc") public class EvaluationReasonTest extends BaseTest { @@ -66,10 +68,27 @@ public void basicProperties() { assertNull(EvaluationReason.prerequisiteFailed("key").getException()); assertNull(EvaluationReason.error(FLAG_NOT_FOUND).getException()); } + + @Test + public void bigSegmentsStatus() { + assertNull(EvaluationReason.off().getBigSegmentsStatus()); + assertNull(EvaluationReason.fallthrough().getBigSegmentsStatus()); + assertNull(EvaluationReason.targetMatch().getBigSegmentsStatus()); + assertNull(EvaluationReason.ruleMatch(1, "id").getBigSegmentsStatus()); + assertNull(EvaluationReason.prerequisiteFailed("key").getBigSegmentsStatus()); + assertNull(EvaluationReason.error(FLAG_NOT_FOUND).getBigSegmentsStatus()); + + EvaluationReason reason = EvaluationReason.fallthrough(); + EvaluationReason withStatus = reason.withBigSegmentsStatus(STALE); + assertEquals(STALE, withStatus.getBigSegmentsStatus()); + assertNull(reason.getBigSegmentsStatus()); + } @Test public void simpleStringRepresentations() { assertEquals("OFF", EvaluationReason.off().toString()); + assertEquals("FALLTHROUGH", EvaluationReason.fallthrough().toString()); + assertEquals("FALLTHROUGH", EvaluationReason.fallthrough().withBigSegmentsStatus(HEALTHY).toString()); assertEquals("TARGET_MATCH", EvaluationReason.targetMatch().toString()); assertEquals("RULE_MATCH(1)", EvaluationReason.ruleMatch(1, null).toString()); assertEquals("RULE_MATCH(1,id)", EvaluationReason.ruleMatch(1, "id").toString()); @@ -101,8 +120,11 @@ public void equalInstancesAreEqual() { List> testValues = asList( asList(EvaluationReason.off(), EvaluationReason.off()), asList(EvaluationReason.fallthrough(), EvaluationReason.fallthrough()), + asList(EvaluationReason.fallthrough().withBigSegmentsStatus(HEALTHY), + EvaluationReason.fallthrough().withBigSegmentsStatus(HEALTHY)), asList(EvaluationReason.targetMatch(), EvaluationReason.targetMatch()), asList(EvaluationReason.ruleMatch(1, "id1"), EvaluationReason.ruleMatch(1, "id1")), + asList(EvaluationReason.ruleMatch(1, "id1", true), EvaluationReason.ruleMatch(1, "id1", true)), asList(EvaluationReason.ruleMatch(1, "id2"), EvaluationReason.ruleMatch(1, "id2")), asList(EvaluationReason.ruleMatch(2, "id1"), EvaluationReason.ruleMatch(2, "id1")), asList(EvaluationReason.prerequisiteFailed("a"), EvaluationReason.prerequisiteFailed("a")), diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java index 0af6d32..5c60e1d 100644 --- a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java @@ -1,15 +1,17 @@ package com.launchdarkly.sdk.json; +import static com.launchdarkly.sdk.EvaluationReason.BigSegmentsStatus.HEALTHY; +import static com.launchdarkly.sdk.EvaluationReason.BigSegmentsStatus.STORE_ERROR; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; + import com.launchdarkly.sdk.BaseTest; import com.launchdarkly.sdk.EvaluationReason; import org.junit.Test; -import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; -import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize; -import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; -import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; - @SuppressWarnings("javadoc") public class EvaluationReasonJsonSerializationTest extends BaseTest { @Test @@ -18,6 +20,8 @@ public void reasonJsonSerializations() throws Exception { verifySerializeAndDeserialize(EvaluationReason.fallthrough(), "{\"kind\":\"FALLTHROUGH\"}"); verifySerializeAndDeserialize(EvaluationReason.fallthrough(false), "{\"kind\":\"FALLTHROUGH\"}"); verifySerializeAndDeserialize(EvaluationReason.fallthrough(true), "{\"kind\":\"FALLTHROUGH\",\"inExperiment\":true}"); + verifySerializeAndDeserialize(EvaluationReason.fallthrough().withBigSegmentsStatus(HEALTHY), + "{\"kind\":\"FALLTHROUGH\",\"bigSegmentsStatus\":\"HEALTHY\"}"); verifySerializeAndDeserialize(EvaluationReason.targetMatch(), "{\"kind\":\"TARGET_MATCH\"}"); verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, "id"), "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\"}"); @@ -25,6 +29,8 @@ public void reasonJsonSerializations() throws Exception { "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\"}"); verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, "id", true), "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\",\"inExperiment\":true}"); + verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, null).withBigSegmentsStatus(STORE_ERROR), + "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"bigSegmentsStatus\":\"STORE_ERROR\"}"); verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, null), "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1}"); verifySerializeAndDeserialize(EvaluationReason.ruleMatch(1, null, false), From 90b5c5eb026fe5cdd9cfdaf552f0daa2e021ed87 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Thu, 12 May 2022 10:43:44 -0700 Subject: [PATCH 63/91] Fix link to CONTRIBUTING.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5821bc4..319c026 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This version of the library works with Java 7 and above. ## Contributing -See [Contributing](https://github.com/launchdarkly/dotnet-sdk-common/blob/master/CONTRIBUTING.md). +See [Contributing](CONTRIBUTING.md). ## About LaunchDarkly From d6a5594b83a9057b97ca9a0b3873ba193de69fa1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 11 Jul 2022 16:31:15 -0700 Subject: [PATCH 64/91] attribute reference type --- .../com/launchdarkly/sdk/AttributeRef.java | 319 ++++++++++++++++++ .../sdk/AttributeRefTypeAdapter.java | 28 ++ .../java/com/launchdarkly/sdk/Errors.java | 8 + .../sdk/json/JsonSerialization.java | 2 + .../launchdarkly/sdk/AttributeRefTest.java | 125 +++++++ .../AttributeRefJsonSerializationTest.java | 39 +++ 6 files changed, 521 insertions(+) create mode 100644 src/main/java/com/launchdarkly/sdk/AttributeRef.java create mode 100644 src/main/java/com/launchdarkly/sdk/AttributeRefTypeAdapter.java create mode 100644 src/main/java/com/launchdarkly/sdk/Errors.java create mode 100644 src/test/java/com/launchdarkly/sdk/AttributeRefTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/json/AttributeRefJsonSerializationTest.java diff --git a/src/main/java/com/launchdarkly/sdk/AttributeRef.java b/src/main/java/com/launchdarkly/sdk/AttributeRef.java new file mode 100644 index 0000000..ecd53a9 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/AttributeRef.java @@ -0,0 +1,319 @@ +package com.launchdarkly.sdk; + +import java.util.HashMap; +import java.util.Map; + +import com.google.gson.annotations.JsonAdapter; +import com.launchdarkly.sdk.json.JsonSerializable; + +/** + * An attribute name or path expression identifying a value within an {@code LDContext}. + *

+ * Applications are unlikely to need to use the AttributeRef type directly, but see below + * for details of the string attribute reference syntax. + *

+ * The reason to use this type directly is to avoid repetitive string parsing in code where + * efficiency is a priority; AttributeRef parses its contents once when it is created, and + * is immutable afterward. If an AttributeRef instance was created from an invalid string, + * it is considered invalid and its {@link #getError()} method will return a non-null error. + *

+ * The string representation of an attribute reference in LaunchDarkly JSON data uses the + * following syntax: + *

    + *
  • If the first character is not a slash, the string is interpreted literally as an + * attribute name. An attribute name can contain any characters, but must not be empty.
  • + *
  • If the first character is a slash, the string is interpreted as a slash-delimited + * path where the first path component is an attribute name, and each subsequent path + * component is either the name of a property in a JSON object, or a decimal numeric string + * that is the index of an element in a JSON array. Any instances of the characters "/" or + * "~" in a path component are escaped as "~1" or "~0" respectively. This syntax + * deliberately resembles JSON Pointer, but no JSON Pointer behaviors other than those + * mentioned here are supported.
  • + *
+ */ +@JsonAdapter(AttributeRefTypeAdapter.class) +public final class AttributeRef implements JsonSerializable { + private static final Map COMMON_LITERALS = makeLiteralsMap( + "kind", "key", "name", "anonymous", // built-ins + "email", "firstName", "lastName", "country", "ip", "avatar" // frequently used custom attributes + ); + + private final String error; + private final String rawPath; + private final String singlePathComponent; + private final Component[] components; + + private static final class Component { + final String name; + final Integer asInteger; + + Component(String name, Integer asInteger) { + this.name = name; + this.asInteger = asInteger; + } + } + + private AttributeRef(String rawPath, String singlePathComponent, Component[] components) { + this.error = null; + this.rawPath = rawPath == null ? "" : rawPath; + this.singlePathComponent = singlePathComponent; + this.components = components; + } + + private AttributeRef(String error, String rawPath) { + this.error = error; + this.rawPath = rawPath == null ? "" : rawPath; + this.singlePathComponent = null; + this.components = null; + } + + /** + * Creates an AttributeRef from a string. For the supported syntax and examples, see + * comments on the {@link AttributeRef} type. + *

+ * This method always returns an AttributeRef that preserves the original string, even if + * validation fails, so that calling {@link #toString()} (or serializing the AttributeRef + * to JSON) will produce the original string. If validation fails, {@link #getError()} will + * return a non-null error and any SDK method that takes this AttributeRef as a parameter + * will consider it invalid. + * + * @param refPath an attribute name or path + * @return an AttributeRef + * @see #fromLiteral(String) + */ + public static AttributeRef fromPath(String refPath) { + if (refPath == null || refPath.isEmpty() || refPath.equals("/")) { + return new AttributeRef(Errors.ATTR_EMPTY, refPath); + } + if (refPath.charAt(0) != '/') { + // When there is no leading slash, this is a simple attribute reference with no character escaping. + return new AttributeRef(refPath, refPath, null); + } + if (refPath.indexOf('/', 1) < 0) { + // There's only one segment, so this is still a simple attribute reference. However, we still may + // need to unescape special characters. + String unescaped = unescapePath(refPath.substring(1)); + if (unescaped == null) { + return new AttributeRef(Errors.ATTR_INVALID_ESCAPE, refPath); + } + return new AttributeRef(refPath, unescaped, null); + } + if (refPath.endsWith("/")) { + // String.split won't behave properly in this case + return new AttributeRef(Errors.ATTR_EXTRA_SLASH, refPath); + } + String[] parsed = refPath.split("/"); + Component[] components = new Component[parsed.length - 1]; + for (int i = 0; i < components.length; i++) { + String p = parsed[i + 1]; + if (p.isEmpty()) { + return new AttributeRef(Errors.ATTR_EXTRA_SLASH, refPath); + } + String unescaped = unescapePath(p); + if (unescaped == null) { + return new AttributeRef(Errors.ATTR_INVALID_ESCAPE, refPath); + } + Integer asInteger = null; + if (Character.isDigit(p.charAt(0))) { + try { + asInteger = Integer.parseInt(p); + } catch (NumberFormatException e) {} + } + components[i] = new Component(unescaped, asInteger); + } + return new AttributeRef(refPath, null, components); + } + + /** + * Similar to {@link #fromPath(String)}, except that it always interprets the string as a literal + * attribute name, never as a slash-delimited path expression. + *

+ * There is no escaping or unescaping, even if the name contains literal '/' or '~' characters. + * Since an attribute name can contain any characters, this method always returns a valid + * AttributeRef unless the name is empty. + *

+ * For example: {@code AttributeRef.fromLiteral("name")} is exactly equivalent to + * {@code AttributeRef.fromPath("name")}. {@code AttributeRef.fromLiteral("a/b")} is exactly + * equivalent to {@code AttributeRef.fromPath("a/b")} (since the syntax used by + * {@link #fromPath(String)} treats the whole string as a literal as long as it does not start + * with a slash), or to {@code AttributeRef.fromPath("/a~1b")}. + * + * @param attributeName an attribute name + * @return an AttributeRef + * @see #fromPath(String) + */ + public static AttributeRef fromLiteral(String attributeName) { + if (attributeName == null || attributeName.isEmpty()) { + return new AttributeRef(Errors.ATTR_EMPTY, ""); + } + if (attributeName.charAt(0) != '/') { + // When there is no leading slash, this is a simple attribute reference with no character escaping. + AttributeRef internedInstance = COMMON_LITERALS.get(attributeName); + return internedInstance == null ? new AttributeRef(attributeName, attributeName, null) : internedInstance; + } + // If there is a leading slash, then the attribute name actually starts with a slash. To represent it + // as an AttributeRef, it'll need to be escaped. + String escapedPath = "/" + attributeName.replace("~", "~0").replace("/", "~1"); + return new AttributeRef(escapedPath, attributeName, null); + } + + /** + * True for a valid AttributeRef, false for an invalid AttributeRef. + *

+ * An AttributeRef can only be invalid for the following reasons: + *

    + *
  • The input string was empty, or consisted only of "/".
  • + *
  • A slash-delimited string had a double slash causing one component to be empty, such as "/a//b".
  • + *
  • A slash-delimited string contained a "~" character that was not followed by "0" or "1".
  • + *
+ *

+ * Otherwise, the AttributeRef is valid, but that does not guarantee that such an attribute exists + * in any given {code LDContext}. For instance, {@code fromLiteral("name")} is a valid AttributeRef, + * but a specific {@code LDContext} might or might not have a name. + *

+ * See comments on the {@link AttributeRef} type for more details of the attribute reference synax. + * + * @return true if the instance is valid + * @see #getError() + */ + public boolean isValid() { + return error == null; + } + + /** + * Null for a valid AttributeRef, or a non-null error message for an invalid AttributeRef. + *

+ * If this is null, then {@link #isValid()} is true. If it is non-null, then {@link #isValid()} is false. + * + * @return an error string or null + * @see #isValid() + */ + public String getError() { + return error; + } + + /** + * The number of path components in the AttributeRef. + *

+ * For a simple attribute reference such as "name" with no leading slash, this returns 1. + *

+ * For an attribute reference with a leading slash, it is the number of slash-delimited path + * components after the initial slash. For instance, {@code AttributeRef.fromPath("/a/b").getDepth()} + * returns 2. + *

+ * For an invalid attribute reference, it returns zero + * + * @return the number of path components + */ + public int getDepth() { + if (error != null) { + return 0; + } + return components == null ? 1 : components.length; + } + + /** + * Retrieves a single path component from the attribute reference. + *

+ * For a simple attribute reference such as "name" with no leading slash, getComponent returns the + * attribute name if index is zero, and null otherwise. + *

+ * For an attribute reference with a leading slash, if index is non-negative and less than + * {@link #getDepth()}, getComponent returns the path component string at that position. + * + * @param index the zero-based index of the desired path component + * @return the path component, or null if not available + */ + public String getComponent(int index) { + if (components == null) { + return index == 0 ? singlePathComponent : null; + } + return index < 0 || index >= components.length ? null : components[index].name; + } + + /** + * Retrieves a single path component from the attribute reference in the form of an integer. + *

+ * This is equivalent to converting the string returned by {@link #getComponent(int)} to an + * integer, or null if it is not a numeric string. + * + * @param index the zero-based index of the desired path component + * @return the path component parsed as an integer, or null + */ + public Integer getComponentAsInteger(int index) { + if (components == null) { + return null; + } + return index < 0 || index >= components.length ? null : components[index].asInteger; + } + + /** + * Returns the attribute reference as a string, in the same format used by + * {@link #fromPath(String)}. + *

+ * If the AttributeRef was created with {@link #fromPath(String)}, this value is identical to + * to the original string. If it was created with {@link #fromLiteral(String)}, the value may + * be different due to unescaping (for instance, an attribute whose name is "/a" would be + * represented as "~1a"). + * + * @return the attribute reference string (guaranteed non-null) + */ + @Override + public String toString() { + return rawPath; + } + + @Override + public boolean equals(Object other) { + if (other instanceof AttributeRef) { + AttributeRef o = (AttributeRef)other; + return rawPath.equals(o.rawPath); + } + return false; + } + + @Override + public int hashCode() { + return rawPath.hashCode(); + } + + private static String unescapePath(String path) { + // If there are no tildes then there's definitely nothing to do + if (path.indexOf('~') < 0) { + return path; + } + StringBuilder ret = new StringBuilder(100); // arbitrary initial capacity + for (int i = 0; i < path.length(); i++) { + char ch = path.charAt(i); + if (ch != '~') + { + ret.append(ch); + continue; + } + i++; + if (i >= path.length()) + { + return null; + } + switch (path.charAt(i)) { + case '0': + ret.append('~'); + break; + case '1': + ret.append('/'); + break; + default: + return null; + } + } + return ret.toString(); + } + + private static Map makeLiteralsMap(String... names) { + Map ret = new HashMap<>(); + for (String name: names) { + ret.put(name, new AttributeRef(name, name, null)); + } + return ret; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/AttributeRefTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/AttributeRefTypeAdapter.java new file mode 100644 index 0000000..0b60869 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/AttributeRefTypeAdapter.java @@ -0,0 +1,28 @@ +package com.launchdarkly.sdk; + +import com.google.gson.JsonParseException; +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 AttributeRefTypeAdapter extends TypeAdapter { + @Override + public AttributeRef read(JsonReader reader) throws IOException { + if (reader.peek() == JsonToken.NULL) { + return null; + } + if (reader.peek() != JsonToken.STRING) { + // We have to do this because Gson's nextString() does not do strict type checking + throw new JsonParseException("expected string, got " + reader.peek()); + } + return AttributeRef.fromPath(reader.nextString()); + } + + @Override + public void write(JsonWriter writer, AttributeRef a) throws IOException { + writer.value(a.toString()); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/Errors.java b/src/main/java/com/launchdarkly/sdk/Errors.java new file mode 100644 index 0000000..4000616 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/Errors.java @@ -0,0 +1,8 @@ +package com.launchdarkly.sdk; + +abstract class Errors { + static final String ATTR_EMPTY = "attribute reference cannot be empty"; + static final String ATTR_EXTRA_SLASH = "attribute reference contained a double slash or a trailing slash"; + static final String ATTR_INVALID_ESCAPE = + "attribute reference contained an escape character (~) that was not followed by 0 or 1"; +} diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index 034717c..47cf71c 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -2,6 +2,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.launchdarkly.sdk.AttributeRef; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; @@ -143,6 +144,7 @@ static Iterable> getDeserializableClasses() { // default case where it *doesn't* exist. This functionality is tested in the Java SDK. synchronized (knownDeserializableClasses) { if (knownDeserializableClasses.isEmpty()) { + knownDeserializableClasses.add(AttributeRef.class); knownDeserializableClasses.add(EvaluationReason.class); knownDeserializableClasses.add(EvaluationDetail.class); knownDeserializableClasses.add(LDUser.class); diff --git a/src/test/java/com/launchdarkly/sdk/AttributeRefTest.java b/src/test/java/com/launchdarkly/sdk/AttributeRefTest.java new file mode 100644 index 0000000..21414cc --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/AttributeRefTest.java @@ -0,0 +1,125 @@ +package com.launchdarkly.sdk; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static java.util.Arrays.asList; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +@SuppressWarnings("javadoc") +public class AttributeRefTest extends BaseTest { + @Test + public void invalidRef() { + testInvalidRef("", Errors.ATTR_EMPTY); + testInvalidRef("/", Errors.ATTR_EMPTY); + testInvalidRef("//", Errors.ATTR_EXTRA_SLASH); + testInvalidRef("/a//b", Errors.ATTR_EXTRA_SLASH); + testInvalidRef("/a/b/", Errors.ATTR_EXTRA_SLASH); + testInvalidRef("/a~x", Errors.ATTR_INVALID_ESCAPE); + testInvalidRef("/a/b~x", Errors.ATTR_INVALID_ESCAPE); + testInvalidRef("/a/b~", Errors.ATTR_INVALID_ESCAPE); + } + + private void testInvalidRef(String s, String expectedError) { + AttributeRef a = AttributeRef.fromPath(s); + assertThat(a.isValid(), is(false)); + assertThat(a.getError(), equalTo(expectedError)); + assertThat(a.toString(), equalTo(s)); + assertThat(a.getDepth(), equalTo(0)); + } + + @Test + public void refWithNoLeadingSlash() { + testRefWithNoLeadingSlash("name"); + testRefWithNoLeadingSlash("name/with/slashes"); + testRefWithNoLeadingSlash("name~0~1with-what-looks-like-escape-sequences"); + } + + private void testRefWithNoLeadingSlash(String s) { + AttributeRef a = AttributeRef.fromPath(s); + assertThat(a.isValid(), is(true)); + assertThat(a.getError(), nullValue()); + assertThat(a.toString(), equalTo(s)); + assertThat(a.getDepth(), equalTo(1)); + assertThat(a.getComponent(0), equalTo(s)); + assertThat(a.getComponentAsInteger(0), nullValue()); + } + + @Test + public void refSimpleWithLeadingSlash() { + testRefSimpleWithLeadingSlash("/name", "name"); + testRefSimpleWithLeadingSlash("/0", "0"); + testRefSimpleWithLeadingSlash("/name~1with~1slashes~0and~0tildes", "name/with/slashes~and~tildes"); + } + + private void testRefSimpleWithLeadingSlash(String s, String unescaped) { + AttributeRef a = AttributeRef.fromPath(s); + assertThat(a.isValid(), is(true)); + assertThat(a.getError(), nullValue()); + assertThat(a.toString(), equalTo(s)); + assertThat(a.getDepth(), equalTo(1)); + assertThat(a.getComponent(0), equalTo(unescaped)); + assertThat(a.getComponentAsInteger(0), nullValue()); + } + + @Test + public void literal() { + testLiteral("name", "name"); + testLiteral("a/b", "a/b"); + testLiteral("/a/b~c", "/~1a~1b~0c"); + testLiteral("/", "/~1"); + } + + private void testLiteral(String s, String escaped) { + AttributeRef a = AttributeRef.fromLiteral(s); + assertThat(a.isValid(), is(true)); + assertThat(a.getError(), nullValue()); + assertThat(a.toString(), equalTo(escaped)); + assertThat(a.getDepth(), equalTo(1)); + assertThat(a.getComponent(0), equalTo(s)); + assertThat(a.getComponentAsInteger(0), nullValue()); + } + + @Test + public void getComponent() { + testGetComponent("", 0, 0, null, null); + testGetComponent("key", 1, 0, "key", null); + testGetComponent("/key", 1, 0, "key", null); + testGetComponent("/a/b", 2, 0, "a", null); + testGetComponent("/a/b", 2, 1, "b", null); + testGetComponent("/a~1b/c", 2, 0, "a/b", null); + testGetComponent("/a~0b/c", 2, 0, "a~b", null); + testGetComponent("/a/10/20/30x", 4, 1, "10", 10); + testGetComponent("/a/10/20/30x", 4, 2, "20", 20); + testGetComponent("/a/10/20/30x", 4, 3, "30x", null); + testGetComponent("", 0, -1, null, null); + testGetComponent("key", 1, -1, null, null); + testGetComponent("key", 1, 1, null, null); + testGetComponent("/key", 1, -1, null, null); + testGetComponent("/key", 1, 1, null, null); + testGetComponent("/a/b", 2, -1, null, null); + testGetComponent("/a/b", 2, 2, null, null); + } + + private void testGetComponent(String input, int depth, int index, String expectedName, Integer expectedAsInt) { + AttributeRef a = AttributeRef.fromPath(input); + assertThat(a.toString(), equalTo(input)); + assertThat(a.getDepth(), equalTo(depth)); + assertThat(a.getComponent(index), equalTo(expectedName)); + assertThat(a.getComponentAsInteger(index), equalTo(expectedAsInt)); + } + + @Test + public void equality() { + List> testValues = new ArrayList<>(); + for (String s: new String[] {"", "a", "b", "/a/b", "/a/c", "///"}) { + testValues.add(asList(AttributeRef.fromPath(s), AttributeRef.fromPath(s))); + } + TestHelpers.doEqualityTests(testValues); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/json/AttributeRefJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/AttributeRefJsonSerializationTest.java new file mode 100644 index 0000000..6ddc093 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/AttributeRefJsonSerializationTest.java @@ -0,0 +1,39 @@ +package com.launchdarkly.sdk.json; + +import com.launchdarkly.sdk.AttributeRef; +import com.launchdarkly.sdk.BaseTest; + +import org.junit.Test; + +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; + +@SuppressWarnings("javadoc") +public class AttributeRefJsonSerializationTest extends BaseTest { + @Test + public void serialization() throws Exception { + testSerialization("a", "\"a\""); + testSerialization("/a/b", "\"/a/b\""); + testSerialization("///invalid", "\"///invalid\""); + } + + private void testSerialization(String attrPath, String expected) throws Exception { + verifySerialize(AttributeRef.fromPath(attrPath), expected); + } + + @Test + public void deserialization() throws Exception { + testDeserialization("\"a\"", "a"); + testDeserialization("\"/a/b\"", "/a/b"); + testDeserialization("\"///invalid\"", "///invalid"); + + verifyDeserializeInvalidJson(AttributeRef.class, "2"); + verifyDeserializeInvalidJson(AttributeRef.class, "[]"); + verifyDeserializeInvalidJson(AttributeRef.class, "{}"); + } + + private void testDeserialization(String json, String attrPath) throws Exception { + verifyDeserialize(AttributeRef.fromPath(attrPath), json); + } +} From 4b68341991e40ee0b97644b721c2c757bb6ef7bf Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 11 Jul 2022 16:42:33 -0700 Subject: [PATCH 65/91] misc cleanup, test coverage --- .../sdk/AttributeRefTypeAdapter.java | 11 +---------- src/main/java/com/launchdarkly/sdk/Errors.java | 2 ++ .../com/launchdarkly/sdk/AttributeRefTest.java | 17 ++++++++++++++++- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/AttributeRefTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/AttributeRefTypeAdapter.java index 0b60869..770d2b9 100644 --- a/src/main/java/com/launchdarkly/sdk/AttributeRefTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/AttributeRefTypeAdapter.java @@ -1,9 +1,7 @@ package com.launchdarkly.sdk; -import com.google.gson.JsonParseException; 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; @@ -11,14 +9,7 @@ final class AttributeRefTypeAdapter extends TypeAdapter { @Override public AttributeRef read(JsonReader reader) throws IOException { - if (reader.peek() == JsonToken.NULL) { - return null; - } - if (reader.peek() != JsonToken.STRING) { - // We have to do this because Gson's nextString() does not do strict type checking - throw new JsonParseException("expected string, got " + reader.peek()); - } - return AttributeRef.fromPath(reader.nextString()); + return AttributeRef.fromPath(Helpers.readNonNullableString(reader)); } @Override diff --git a/src/main/java/com/launchdarkly/sdk/Errors.java b/src/main/java/com/launchdarkly/sdk/Errors.java index 4000616..5d34e48 100644 --- a/src/main/java/com/launchdarkly/sdk/Errors.java +++ b/src/main/java/com/launchdarkly/sdk/Errors.java @@ -1,6 +1,8 @@ package com.launchdarkly.sdk; abstract class Errors { + private Errors() {} + static final String ATTR_EMPTY = "attribute reference cannot be empty"; static final String ATTR_EXTRA_SLASH = "attribute reference contained a double slash or a trailing slash"; static final String ATTR_INVALID_ESCAPE = diff --git a/src/test/java/com/launchdarkly/sdk/AttributeRefTest.java b/src/test/java/com/launchdarkly/sdk/AttributeRefTest.java index 21414cc..8fb726d 100644 --- a/src/test/java/com/launchdarkly/sdk/AttributeRefTest.java +++ b/src/test/java/com/launchdarkly/sdk/AttributeRefTest.java @@ -15,6 +15,7 @@ public class AttributeRefTest extends BaseTest { @Test public void invalidRef() { + testInvalidRef(null, Errors.ATTR_EMPTY); testInvalidRef("", Errors.ATTR_EMPTY); testInvalidRef("/", Errors.ATTR_EMPTY); testInvalidRef("//", Errors.ATTR_EXTRA_SLASH); @@ -29,7 +30,21 @@ private void testInvalidRef(String s, String expectedError) { AttributeRef a = AttributeRef.fromPath(s); assertThat(a.isValid(), is(false)); assertThat(a.getError(), equalTo(expectedError)); - assertThat(a.toString(), equalTo(s)); + assertThat(a.toString(), equalTo(s == null ? "" : s)); + assertThat(a.getDepth(), equalTo(0)); + } + + @Test + public void invalidLiteral() { + testInvalidLiteral(null, Errors.ATTR_EMPTY); + testInvalidLiteral("", Errors.ATTR_EMPTY); + } + + private void testInvalidLiteral(String s, String expectedError) { + AttributeRef a = AttributeRef.fromLiteral(s); + assertThat(a.isValid(), is(false)); + assertThat(a.getError(), equalTo(expectedError)); + assertThat(a.toString(), equalTo(s == null ? "" : s)); assertThat(a.getDepth(), equalTo(0)); } From eeb0921c264a5160bc81471a5cdd3178d1596919 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 11 Jul 2022 22:51:03 -0700 Subject: [PATCH 66/91] add context type & builders --- .../src/main/kotlin/TestCoverageOverrides.kt | 1 + .../com/launchdarkly/sdk/AttributeRef.java | 16 +- .../com/launchdarkly/sdk/ContextBuilder.java | 415 +++++++++ .../com/launchdarkly/sdk/ContextKind.java | 108 +++ .../launchdarkly/sdk/ContextMultiBuilder.java | 90 ++ .../java/com/launchdarkly/sdk/Errors.java | 8 + .../java/com/launchdarkly/sdk/LDContext.java | 826 ++++++++++++++++++ .../java/com/launchdarkly/sdk/LDValue.java | 1 - .../launchdarkly/sdk/ContextBuilderTest.java | 138 +++ .../com/launchdarkly/sdk/ContextKindTest.java | 53 ++ .../sdk/ContextMultiBuilderTest.java | 74 ++ .../com/launchdarkly/sdk/LDContextTest.java | 386 ++++++++ 12 files changed, 2110 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/ContextBuilder.java create mode 100644 src/main/java/com/launchdarkly/sdk/ContextKind.java create mode 100644 src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java create mode 100644 src/main/java/com/launchdarkly/sdk/LDContext.java create mode 100644 src/test/java/com/launchdarkly/sdk/ContextBuilderTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/ContextKindTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/LDContextTest.java diff --git a/buildSrc/src/main/kotlin/TestCoverageOverrides.kt b/buildSrc/src/main/kotlin/TestCoverageOverrides.kt index 8014382..697eef6 100644 --- a/buildSrc/src/main/kotlin/TestCoverageOverrides.kt +++ b/buildSrc/src/main/kotlin/TestCoverageOverrides.kt @@ -15,6 +15,7 @@ object TestCoverageOverrides { val methodsWithMissedLineCount = mapOf( "EvaluationReason.error(com.launchdarkly.sdk.EvaluationReason.ErrorKind)" to 1, "EvaluationReasonTypeAdapter.parse(com.google.gson.stream.JsonReader)" to 1, + "LDContext.urlEncodeKey(java.lang.String)" to 2, "LDValue.equals(java.lang.Object)" to 1, "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)" to 1, "json.LDGson.LDTypeAdapter.write(com.google.gson.stream.JsonWriter, java.lang.Object)" to 1, diff --git a/src/main/java/com/launchdarkly/sdk/AttributeRef.java b/src/main/java/com/launchdarkly/sdk/AttributeRef.java index ecd53a9..adccfe0 100644 --- a/src/main/java/com/launchdarkly/sdk/AttributeRef.java +++ b/src/main/java/com/launchdarkly/sdk/AttributeRef.java @@ -7,10 +7,11 @@ import com.launchdarkly.sdk.json.JsonSerializable; /** - * An attribute name or path expression identifying a value within an {@code LDContext}. + * An attribute name or path expression identifying a value within an {@link LDContext}. *

* Applications are unlikely to need to use the AttributeRef type directly, but see below - * for details of the string attribute reference syntax. + * for details of the string attribute reference syntax used by methods like + * {@link ContextBuilder#privateAttributes(String...)}. *

* The reason to use this type directly is to avoid repetitive string parsing in code where * efficiency is a priority; AttributeRef parses its contents once when it is created, and @@ -32,7 +33,7 @@ * */ @JsonAdapter(AttributeRefTypeAdapter.class) -public final class AttributeRef implements JsonSerializable { +public final class AttributeRef implements JsonSerializable, Comparable { private static final Map COMMON_LITERALS = makeLiteralsMap( "kind", "key", "name", "anonymous", // built-ins "email", "firstName", "lastName", "country", "ip", "avatar" // frequently used custom attributes @@ -168,8 +169,8 @@ public static AttributeRef fromLiteral(String attributeName) { * *

* Otherwise, the AttributeRef is valid, but that does not guarantee that such an attribute exists - * in any given {code LDContext}. For instance, {@code fromLiteral("name")} is a valid AttributeRef, - * but a specific {@code LDContext} might or might not have a name. + * in any given {@link LDContext}. For instance, {@code fromLiteral("name")} is a valid AttributeRef, + * but a specific {@link LDContext} might or might not have a name. *

* See comments on the {@link AttributeRef} type for more details of the attribute reference synax. * @@ -277,6 +278,11 @@ public int hashCode() { return rawPath.hashCode(); } + @Override + public int compareTo(AttributeRef o) { + return rawPath.compareTo(o.rawPath); + } + private static String unescapePath(String path) { // If there are no tildes then there's definitely nothing to do if (path.indexOf('~') < 0) { diff --git a/src/main/java/com/launchdarkly/sdk/ContextBuilder.java b/src/main/java/com/launchdarkly/sdk/ContextBuilder.java new file mode 100644 index 0000000..ac195b4 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/ContextBuilder.java @@ -0,0 +1,415 @@ +package com.launchdarkly.sdk; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A mutable object that uses the builder pattern to specify properties for {@link LDContext}. + *

+ * Use this type if you need to construct a context that has only a single kind. To define a + * multi-kind context, use {@link LDContext#createMulti(LDContext...)} or + * {@link LDContext#multiBuilder()}. + *

+ * Obtain an instance of ContextBuilder by calling {@link LDContext#builder(String)} or + * {@link LDContext#builder(ContextKind, String)}. Then, call setter methods such as + * {@link #name(String)} or {@link #set(String, String)} to specify any additional attributes. + * Then, call {@link #build()} to create the context. ContextBuilder setters return a reference + * to the same builder, so calls can be + * chained: + *


+ *     LDContext context = LDContext.builder("user-key")
+ *       .name("my-name)
+ *       .set("country", "us")
+ *       .build();
+ * 
+ *

+ * A ContextBuilder should not be accessed by multiple threads at once. Once you have called + * {@link #build()}, the resulting LDContext is immutable and is safe to use from multiple + * threads. Instances created with {@link #build()} are not affected by subsequent actions + * taken on the builder. + */ +public final class ContextBuilder { + private ContextKind kind; + private String key; + private String name; + private Map attributes; + private String secondary; + private boolean anonymous; + private List privateAttributes; + private boolean copyOnWriteAttributes; + private boolean copyOnWritePrivateAttributes; + + ContextBuilder() {} + + ContextBuilder(ContextKind kind, String key) { + this.kind = kind; + this.key = key; + } + + /** + * Creates an {@link LDContext} from the current builder properties. + *

+ * The LDContext is immutable and will not be affected by any subsequent actions on the + * ContextBuilder. + *

+ * It is possible to specify invalid attributes for a ContextBuilder, such as an empty key. + * Instead of throwing an exception, the ContextBuilder always returns an LDContext and + * you can check {@link LDContext#isValid()} or {@link LDContext#getError()} to see if it + * has an error. See {@link LDContext#isValid()} for more information about invalid + * conditions. If you pass an invalid LDContext to an SDK method, the SDK will detect this + * and will log a description of the error. + * + * @return a new {@link LDContext} + */ + public LDContext build() { + this.copyOnWriteAttributes = attributes != null; + this.copyOnWritePrivateAttributes = privateAttributes != null; + + return LDContext.createSingle(kind, key, name, attributes, secondary, anonymous, privateAttributes); + } + + /** + * Sets the context's kind attribute. + *

+ * Every LDContext has a kind. Setting it to an empty string or null is equivalent to + * {@link ContextKind#DEFAULT} ("user"). This value is case-sensitive. For validation + * rules, see {@link ContextKind}. + * + * @param kind the context kind + * @return the builder + * @see LDContext#getKind() + */ + public ContextBuilder kind(ContextKind kind) { + this.kind = kind; + return this; + } + + /** + * Sets the context's kind attribute, as a string. + *

+ * This method is a shortcut for calling {@code kind(ContextKind.of(kindName))}, since the + * method name already prevents ambiguity about the intended type + * + * @param kindString the context kind + * @return the builder + * @see LDContext#getKind() + */ + public ContextBuilder kind(String kindString) { + return kind(ContextKind.of(kindString)); + } + + /** + * Sets the context's key attribute. + *

+ * Every Context has a key, which is always a string. It cannot be an empty string, but + * there are no other restrictions on its value. + *

+ * The key attribute can be referenced by flag rules, flag target lists, and segments. + * + * @param key the context key + * @return the builder + * @see LDContext#getKey() + */ + public ContextBuilder key(String key) { + this.key = key; + return this; + } + + /** + * Sets the context's name attribute. + *

+ * This attribute is optional. It has the following special rules: + *

    + *
  • Unlike most other attributes, it is always a string if it is specified.
+ *
  • The LaunchDarkly dashboard treats this attribute as the preferred display name + * for contexts.
  • + * + * + * @param name the name attribute (null to unset the attribute) + * @return the builder + * @see LDContext#getName() + */ + public ContextBuilder name(String name) { + this.name = name; + return this; + } + + /** + * Sets whether the context is only intended for flag evaluations and should not be + * indexed by LaunchDarkly. + *

    + * The default value is false. False means that this LDContext represents an entity + * such as a user that you want to be able to see on the LaunchDarkly dashboard. + *

    + * Setting {@code anonymous} to true excludes this context from the database that is + * used by the dashboard. It does not exclude it from analytics event data, so it is + * not the same as making attributes private; all non-private attributes will still be + * included in events and data export. There is no limitation on what other attributes + * may be included (so, for instance, {@code anonymous} does not mean there is no + * {@code name}), and the context will still have whatever {@code key} you have given it. + *

    + * This value is also addressable in evaluations as the attribute name "anonymous". It + * is always treated as a boolean true or false in evaluations. + * + * @param anonymous true if the context should be excluded from the LaunchDarkly database + * @return the builder + * @see LDContext#isAnonymous() + */ + public ContextBuilder anonymous(boolean anonymous) { + this.anonymous = anonymous; + return this; + } + + /** + * Sets a secondary key for the context. + *

    + * This affects + * feature flag targeting as follows: if you have chosen to bucket contexts by a + * specific attribute, the secondary key (if set) is used to further distinguish between + * contexts that are otherwise identical according to that attribute. + *

    + * This is a metadata property, rather than an attribute that can be addressed in + * evaluations: that is, a rule clause that references the attribute name "secondary" + * will not use this value, but instead will use whatever value (if any) you have set + * for the name "secondary" with a method such as {@link #set(String, String)}). + *

    + * Setting this value to an empty string is not the same as leaving it unset. If you + * need to clear this, set it to null. + * + * @param secondary the secondary key, or null + * @return the builder + * @see LDContext#getSecondary() + */ + public ContextBuilder secondary(String secondary) { + this.secondary = secondary; + return this; + } + + /** + * Sets the value of any attribute for the context. + *

    + * This includes only attributes that are addressable in evaluations-- not metadata + * such as {@link #secondary(String)}. If {@code attributeName} is "secondary" or + * "privateAttributes", you will be setting an attribute with that name which you can + * use in evaluations or to record data for your own purposes, but it will be unrelated + * to {@link #secondary(String)} and {@link #privateAttributes(String...)}. + *

    + * This method uses the {@link LDValue} type to represent a value of any JSON type: + * null, boolean, number, string, array, or object. For all attribute names that do + * not have special meaning to LaunchDarkly, you may use any of those types. Values of + * different JSON types are always treated as different values: for instance, null, + * false, and the empty string "" are not the the same, and the number 1 is not the + * same as the string "1". + *

    + * The following attribute names have special restrictions on their value types, and + * any value of an unsupported type will be ignored (leaving the attribute unchanged): + *

      + *
    • "kind", "key": Must be a string. See {@link #kind(ContextKind)} and + * {@link #key(String)}.
    • + *
    • "name": Must be a string or null. See {@link #name(String)}.
    • + *
    • "anonymous": Must be a boolean. See {@link #anonymous(boolean)}.
    • + *
    + *

    + * The attribute name "_meta" is not allowed, because it has special meaning in the + * JSON schema for contexts; any attempt to set an attribute with this name has no + * effect. + *

    + * Values that are JSON arrays or objects have special behavior when referenced in + * flag/segment rules. + *

    + * A value of {@code null} or {@link LDValue#ofNull()} is equivalent to removing any + * current non-default value of the attribute. Null is not a valid attribute value in + * the LaunchDarkly model; any expressions in feature flags that reference an attribute + * with a null value will behave as if the attribute did not exist. + * + * @param attributeName the attribute name to set + * @param value the value to set + * @return the builder + * @see #set(String, boolean) + * @see #set(String, int) + * @see #set(String, double) + * @see #set(String, String) + * @see #trySet(String, LDValue) + */ + public ContextBuilder set(String attributeName, LDValue value) { + trySet(attributeName, value); + return this; + } + + /** + * Same as {@link #set(String, LDValue)} for a boolean value. + * + * @param attributeName the attribute name to set + * @param value the value to set + * @return the builder + * @see #set(String, LDValue) + */ + public ContextBuilder set(String attributeName, boolean value) { + return set(attributeName, LDValue.of(value)); + } + + /** + * Same as {@link #set(String, LDValue)} for an integer numeric value. + * + * @param attributeName the attribute name to set + * @param value the value to set + * @return the builder + * @see #set(String, LDValue) + */ + public ContextBuilder set(String attributeName, int value) { + return set(attributeName, LDValue.of(value)); + } + + /** + * Same as {@link #set(String, LDValue)} for a double-precision numeric value. + * + * @param attributeName the attribute name to set + * @param value the value to set + * @return the builder + * @see #set(String, LDValue) + */ + public ContextBuilder set(String attributeName, double value) { + return set(attributeName, LDValue.of(value)); + } + + /** + * Same as {@link #set(String, LDValue)} for a string value. + * + * @param attributeName the attribute name to set + * @param value the value to set + * @return the builder + * @see #set(String, LDValue) + */ + public ContextBuilder set(String attributeName, String value) { + return set(attributeName, LDValue.of(value)); + } + + /** + * Same as {@link #set(String, LDValue)}, but returns a boolean indicating whether + * the attribute was successfully set. + * + * @param attributeName the attribute name to set + * @param value the value to set + * @return true if successful; false if the name was invalid or the value was not + * an allowed type for that attribute + */ + public boolean trySet(String attributeName, LDValue value) { + if (attributeName == null || attributeName.isEmpty()) { + return false; + } + switch (attributeName) { + case "kind": + if (!value.isString()) { + return false; + } + kind = ContextKind.of(value.stringValue()); + break; + case "key": + if (!value.isString()) { + return false; + } + key = value.stringValue(); + break; + case "name": + if (!value.isString() && !value.isNull()) { + return false; + } + name = value.stringValue(); + break; + case "anonymous": + if (value.getType() != LDValueType.BOOLEAN) { + return false; + } + anonymous = value.booleanValue(); + break; + case "_meta": + return false; + default: + if (copyOnWriteAttributes) { + attributes = new HashMap<>(attributes); + copyOnWriteAttributes = false; + } + if (value == null || value.isNull()) { + if (attributes != null) { + attributes.remove(attributeName); + } + } else { + if (attributes == null) { + attributes = new HashMap<>(); + } + attributes.put(attributeName, value); + } + } + return true; + } + + /** + * Designates any number of context attributes, or properties within them, as private: + * that is, their values will not be recorded by LaunchDarkly. + *

    + * Each parameter can be either a simple attribute name (like "email"), or an attribute + * reference in the syntax described for {@link AttributeRef} (like "/address/street"). + * + * @param attributeRefs attribute references to mark as private + * @return the builder + */ + public ContextBuilder privateAttributes(String... attributeRefs) { + if (attributeRefs == null || attributeRefs.length == 0) { + return this; + } + prepareToChangePrivate(); + for (String a: attributeRefs) { + privateAttributes.add(AttributeRef.fromPath(a)); + } + return this; + } + + /** + * Equivalent to {@link #privateAttributes(String...)}, but uses the {@link AttributeRef} + * type. + *

    + * Application code is unlikely to need to use the {@link AttributeRef} type directly; + * however, in cases where you are constructing LDContexts constructed repeatedly with + * the same set of private attributes, if you are also using complex private attribute + * path references such as "/address/street", converting this to an AttributeRef once + * and reusing it in many {@code privateAttribute} calls is slightly more efficient + * than passing a string (since it does not need to parse the path repeatedly). + * + * @param attributeRefs attribute references to mark as private + * @return the builder + */ + public ContextBuilder privateAttributes(AttributeRef... attributeRefs) { + if (attributeRefs == null || attributeRefs.length == 0) { + return this; + } + prepareToChangePrivate(); + for (AttributeRef a: attributeRefs) { + privateAttributes.add(a); + } + return this; + } + + ContextBuilder copyFrom(LDContext context) { + kind = context.getKind(); + key = context.getKey(); + name = context.getName(); + anonymous = context.isAnonymous(); + secondary = context.getSecondary(); + attributes = context.attributes; + privateAttributes = context.privateAttributes; + copyOnWriteAttributes = true; + copyOnWritePrivateAttributes = true; + return this; + } + + private void prepareToChangePrivate() { + if (copyOnWritePrivateAttributes) { + privateAttributes = new ArrayList<>(privateAttributes); + copyOnWritePrivateAttributes = false; + } else if (privateAttributes == null) { + privateAttributes = new ArrayList<>(); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/ContextKind.java b/src/main/java/com/launchdarkly/sdk/ContextKind.java new file mode 100644 index 0000000..b152f7a --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/ContextKind.java @@ -0,0 +1,108 @@ +package com.launchdarkly.sdk; + +/** + * A string identifier provided by the application to describe what kind of entity an + * {@link LDContext} represents. + *

    + * The type is a simple wrapper for a String. Using a type that is not just String + * makes it clearer where a context kind is expected or returned in the SDK API, so it + * cannot be confused with other important strings such as the context key. To convert + * a literal string to this type, use the factory method {@link #of(String)}. + *

    + * The meaning of the context kind is completely up to the application. Validation rules are + * as follows: + *

      + *
    • It may only contain letters, numbers, and the characters ".", "_", and "-".
    • + *
    • It cannot equal the literal string "kind".
    • + *
    • For a single-kind context, it cannot equal "multi". + *
    + *

    + * If no kind is specified, the default is "user" (the constant {@link #DEFAULT}). + *

    + * For a multi-kind context (see {@link LDContext#createMulti(LDContext...)}), the kind of + * the top-level LDContext is always "multi" (the constant {@link #MULTI}); there is a + * specific Kind for each of the contexts contained within it. + */ +public final class ContextKind { + /** + * A constant for the default kind of "user". + */ + public static final ContextKind DEFAULT = new ContextKind("user"); + + /** + * A constant for the kind that all multi-kind contexts have. + */ + public static final ContextKind MULTI = new ContextKind("multi"); + + private final String kindName; + + private ContextKind(String kindName) { + this.kindName = kindName; + } + + /** + * Constructor from a string value. + *

    + * A value of null or "" will be changed to {@link #DEFAULT}. + * + * @param stringValue the string value + * @return a ContextKind + */ + public static ContextKind of(String stringValue) { + if (stringValue == null || stringValue.isEmpty() || stringValue.equals(DEFAULT.kindName)) { + return DEFAULT; + } + if (stringValue.equals(MULTI.kindName)) { + return MULTI; + } + return new ContextKind(stringValue); + } + + /** + * True if this is equal to {@link #DEFAULT} ("user"). + * @return true if this is the default kind + */ + public boolean isDefault() { + return this == DEFAULT; // can use == here because of() ensures there's only one instance with that value + } + + /** + * Returns the string value of the context kind. This is never null. + */ + @Override + public String toString() { + return kindName; + } + + @Override + public boolean equals(Object other) { + return other instanceof ContextKind && + (this == other || kindName.equals(((ContextKind)other).kindName)); + } + + @Override + public int hashCode() { + return kindName.hashCode(); + } + + String validateAsSingleKind() { + if (isDefault()) { + return null; + } + if (this == MULTI) { + return Errors.CONTEXT_KIND_MULTI_FOR_SINGLE; + } + if (kindName.equals("kind")) { + return Errors.CONTEXT_KIND_CANNOT_BE_KIND; + } + for (int i = 0; i < kindName.length(); i++) { + char ch = kindName.charAt(i); + if ((ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') && (ch < '0' || ch > '9') && + ch != '.' && ch != '_' && ch != '-') + { + return Errors.CONTEXT_KIND_INVALID_CHARS; + } + } + return null; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java b/src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java new file mode 100644 index 0000000..49c0a9f --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java @@ -0,0 +1,90 @@ +package com.launchdarkly.sdk; + +import java.util.ArrayList; +import java.util.List; + +/** + * A mutable object that uses the builder pattern to specify properties for a multi-kind + * {@link LDContext}. + *

    + * Use this builder if you need to construct a context that has multiple {@link ContextKind} + * values, each with its own corresponding LDContext. To define a single-kind context, + * use {@link LDContext#builder(String)} or any of the single-kind factory methods + * in {@link LDContext}. + *

    + * Obtain an instance of ContextMultiBuilder by calling {@link LDContext#multiBuilder()}; + * then, call {@link #add(LDContext)} to specify the individual context for each kind. The + * {@link #add(LDContext)} method returns a reference to the same builder, so calls can be + * chained: + *

    
    + *     LDContext context = LDContext.multiBuilder()
    + *       .add(LDContext.create("my-user-key"))
    + *       .add(LDContext.create(ContextKind.of("organization"), "my-org-key"))
    + *       .build();
    + * 
    + *

    + * A ContextMultiBuilder should not be accessed by multiple threads at once. Once you have + * called {@link #build()}, the resulting LDContext is immutable and is safe to use from + * multiple threads. Instances created with {@link #build()} are not affected by subsequent + * actions taken on the builder. + * + * @see LDContext#createMulti(LDContext...) + */ +public final class ContextMultiBuilder { + private List contexts; + private boolean copyOnWrite; + + ContextMultiBuilder() {} + + /** + * Creates an {@link LDContext} from the current builder properties. + *

    + * The LDContext is immutable and will not be affected by any subsequent actions on the + * builder. + *

    + * It is possible for a ContextMultiBuilder to represent an invalid state. Instead of + * throwing an exception, the ContextMultiBuilder always returns an LDContext, and you + * can check {@link LDContext#isValid()} or {@link LDContext#getError()} to see if it + * has an error. See {@link LDContext#isValid()} for more information about invalid + * context conditions. If you pass an invalid context to an SDK method, the SDK will + * detect this and will log a description of the error. + *

    + * If only one context kind was added to the builder, this method returns a single-kind + * LDContext rather than a multi-kind one. + * + * @return a new {@link LDContext} + */ + public LDContext build() { + if (contexts == null || contexts.size() == 0) { + return LDContext.failed(Errors.CONTEXT_KIND_MULTI_WITH_NO_KINDS); + } + if (contexts.size() == 1) { + return contexts.get(0); + } + + copyOnWrite = contexts != null; + return LDContext.createMulti(contexts); + } + + /** + * Adds an individual LDContext for a specific kind to the builer. + *

    + * It is invalid to add more than one LDContext for the same kind, or to add a nested + * multi-kind LDContext. This error is detected when you call {@link #build()}. + * + * @param context the context to add + * @return the builder + */ + public ContextMultiBuilder add(LDContext context) { + if (context != null) { + if (copyOnWrite) { + contexts = new ArrayList<>(contexts); + copyOnWrite = false; + } else if (contexts == null) { + contexts = new ArrayList<>(); + } + contexts.add(context); + } + return this; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/Errors.java b/src/main/java/com/launchdarkly/sdk/Errors.java index 5d34e48..e517710 100644 --- a/src/main/java/com/launchdarkly/sdk/Errors.java +++ b/src/main/java/com/launchdarkly/sdk/Errors.java @@ -7,4 +7,12 @@ private Errors() {} static final String ATTR_EXTRA_SLASH = "attribute reference contained a double slash or a trailing slash"; static final String ATTR_INVALID_ESCAPE = "attribute reference contained an escape character (~) that was not followed by 0 or 1"; + + static final String CONTEXT_NO_KEY = "context key must not be null or empty"; + static final String CONTEXT_KIND_CANNOT_BE_KIND = "\"kind\" is not a valid context kind"; + static final String CONTEXT_KIND_INVALID_CHARS = "context kind contains disallowed characters"; + static final String CONTEXT_KIND_MULTI_FOR_SINGLE = "context of kind \"multi\" must be created with NewMulti or NewMultiBuilder"; + static final String CONTEXT_KIND_MULTI_WITH_NO_KINDS = "multi-kind context must contain at least one kind"; + static final String CONTEXT_KIND_MULTI_WITHIN_MULTI = "multi-kind context cannot contain other multi-kind contexts"; + static final String CONTEXT_KIND_MULTI_DUPLICATES = "multi-kind context cannot have same kind more than once"; } diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java new file mode 100644 index 0000000..0373839 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDContext.java @@ -0,0 +1,826 @@ +package com.launchdarkly.sdk; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * A collection of attributes that can be referenced in flag evaluations and analytics events. + *

    + * To create an LDContext of a single kind, such as a user, you may use {@link #create(String)} + * or {@link #create(ContextKind, String)} when only the key matters; or, to specify other + * attributes, use {@link #builder(String)}. + *

    + * To create an LDContext with multiple kinds, use {@link #createMulti(LDContext...)} or + * {@link #multiBuilder()}. + *

    + * An LDContext can be in an error state if it was built with invalid attributes. See + * {@link #isValid()} and {@link #getError()}. + */ +public final class LDContext { + final String error; + final ContextKind kind; + final List multiContexts; + final String key; + final String fullyQualifiedKey; + final String name; + final Map attributes; + final String secondary; + final boolean anonymous; + final List privateAttributes; + + private LDContext( + ContextKind kind, + List multiContexts, + String key, + String fullyQualifiedKey, + String name, + Map attributes, + String secondary, + boolean anonymous, + List privateAttributes + ) { + this.error = null; + this.kind = kind == null ? ContextKind.DEFAULT : kind; + this.multiContexts = multiContexts; + this.key = key; + this.fullyQualifiedKey = fullyQualifiedKey; + this.name = name; + this.attributes = attributes; + this.secondary = secondary; + this.anonymous = anonymous; + this.privateAttributes = privateAttributes; + } + + private LDContext(String error) { + this.error = error; + this.kind = null; + this.multiContexts = null; + this.key = ""; + this.fullyQualifiedKey = ""; + this.name = null; + this.attributes = null; + this.secondary = null; + this.anonymous = false; + this.privateAttributes = null; + } + + // Internal factory method for single-kind contexts. + static LDContext createSingle( + ContextKind kind, + String key, + String name, + Map attributes, + String secondary, + boolean anonymous, + List privateAttributes + ) { + if (kind != null) { + String error = kind.validateAsSingleKind(); + if (error != null) { + return failed(error); + } + } + if (key == null || key.isEmpty()) { + return failed(Errors.CONTEXT_NO_KEY); + } + String fullyQualifiedKey = kind.isDefault() ? key : + (kind.toString() + ":" + urlEncodeKey(key)); + return new LDContext(kind, null, key, fullyQualifiedKey, name, attributes, secondary, anonymous, privateAttributes); + } + + // Internal factory method for multi-kind contexts - implements all of the validation logic + // except for validating that there is more than one context. We take ownership of the list + // that is passed in, so it is effectively immutable afterward; ContextMultiBuilder has + // copy-on-write logic to manage that. + static LDContext createMulti(List multiContexts) { + List errors = null; + boolean nestedMulti = false, duplicates = false; + for (int i = 0; i < multiContexts.size(); i++) { + LDContext c = multiContexts.get(i); + if (!c.isValid()) { + if (errors == null) { + errors = new ArrayList(); + } + errors.add(c.getError()); + } else if (c.isMultiple()) { + nestedMulti = true; + } else { + for (int j = 0; j < i; j++) { + if (multiContexts.get(j).getKind().equals(c.getKind())) { + duplicates = true; + break; + } + } + } + } + if (nestedMulti) { + if (errors == null) { + errors = new ArrayList(); + } + errors.add(Errors.CONTEXT_KIND_MULTI_WITHIN_MULTI); + } + if (duplicates) { + if (errors == null) { + errors = new ArrayList(); + } + errors.add(Errors.CONTEXT_KIND_MULTI_DUPLICATES); + } + + if (errors != null) { + StringBuilder s = new StringBuilder(); + for (String e: errors) { + if (s.length() != 0) { + s.append(", "); + } + s.append(e); + } + return failed(s.toString()); + } + + multiContexts.sort(ByKindComparator.INSTNACE); + StringBuilder fullKey = new StringBuilder(); + for (LDContext c: multiContexts) { + if (fullKey.length() != 0) { + fullKey.append(':'); + } + fullKey.append(c.getKind().toString()).append(':').append(urlEncodeKey(c.getKey())); + } + return new LDContext(ContextKind.MULTI, multiContexts, "", fullKey.toString(), + null, null, null, false, null); + } + + // Internal factory method for a context in an invalid state. + static LDContext failed(String error) { + return new LDContext(error); + } + + /** + * Creates a single-kind LDContext with a kind of {@link ContextKind#DEFAULT}} and the specified key. + *

    + * To specify additional properties, use {@link #builder(String)}. To create a multi-kind + * LDContext, use {@link #createMulti(LDContext...)} or {@link #multiBuilder()}. To create a + * single-kind LDContext of a different kind than "user", use {@link #create(ContextKind, String)}. + * + * @param key the context key + * @return an LDContext + * @see #create(ContextKind, String) + * @see #builder(String) + */ + public static LDContext create(String key) { + return create(ContextKind.DEFAULT, key); + } + + /** + * Creates a single-kind LDContext with only the kind and keys specified. + *

    + * To specify additional properties, use {@link #builder(ContextKind, String)}. To create a multi-kind + * LDContext, use {@link #createMulti(LDContext...)} or {@link #multiBuilder()}. + * + * @param kind the context kind; if null, {@link ContextKind#DEFAULT} will be used + * @param key the context key + * @return an LDContext + * @see #create(String) + * @see #builder(ContextKind, String) + */ + public static LDContext create(ContextKind kind, String key) { + return createSingle(kind, key, null, null, null, false, null); + } + + /** + * Creates a multi-kind LDContext out of the specified single-kind LDContexts. + *

    + * To create a single-kind Context, use {@link #create(String)}, {@link #create(ContextKind, String)}, + * or {@link #builder(String)}. + *

    + * For the returned LDContext to be valid, the contexts list must not be empty, and all of its + * elements must be single-kind LDContexts. Otherwise, the returned LDContext will be invalid as + * reported by {@link #getError()}. + *

    + * If only one context parameter is given, the method returns a single-kind context (that is, + * just that same context) rather than a multi-kind context. + * + * @param contexts a list of contexts + * @return an LDContext + * @see #multiBuilder() + */ + public static LDContext createMulti(LDContext... contexts) { + if (contexts == null || contexts.length == 0) { + return failed(Errors.CONTEXT_KIND_MULTI_WITH_NO_KINDS); + } + if (contexts.length == 1) { + return contexts[0]; // just return a single-kind context + } + return createMulti(Arrays.asList(contexts)); + } + + /** + * Creates a {@link ContextBuilder} for building an LDContext, initializing its {@code key} and setting + * {@code kind} to {@link ContextKind#DEFAULT}. + *

    + * You may use {@link ContextBuilder} methods to set additional attributes and/or change the + * {@link ContextBuilder#kind(ContextKind)} before calling {@link ContextBuilder#build()}. + * If you do not change any values, the defaults for the LDContext are that its {@code kind} is + * {@link ContextKind#DEFAULT} ("user"), its {@code key} is set to the key parameter passed here, + * {@code anonymous} is {@code false}, and it has no values for any other attributes. + *

    + * This method is for building an LDContext that has only a single Kind. To define a multi-kind + * LDContext, use {@link #multiBuilder()}. + *

    + * if {@code key} is an empty string, there is no default. An LDContext must have a non-empty + * key, so if you call {@link ContextBuilder#build()} in this state without using + * {@link ContextBuilder#key(String)} to set the key, you will get an invalid LDContext. + * + * @param key the context key + * @return a builder + * @see #builder(ContextKind, String) + * @see #multiBuilder() + * @see #create(String) + */ + public static ContextBuilder builder(String key) { + return builder(ContextKind.DEFAULT, key); + } + + /** + * Creates a {@link ContextBuilder} for building an LDContext, initializing its {@code key} and + * {@code kind}. + *

    + * You may use {@link ContextBuilder} methods to set additional attributes and/or change the + * {@link ContextBuilder#kind(ContextKind)} before calling {@link ContextBuilder#build()}. + * If you do not change any values, the defaults for the LDContext are that its {@code kind} and + * {@code key} is set to the parameters passed here, {@code anonymous} is {@code false}, and it has + * no values for any other attributes. + *

    + * This method is for building an LDContext that has only a single Kind. To define a multi-kind + * LDContext, use {@link #multiBuilder()}. + *

    + * if {@code key} is an empty string, there is no default. An LDContext must have a non-empty + * key, so if you call {@link ContextBuilder#build()} in this state without using + * {@link ContextBuilder#key(String)} to set the key, you will get an invalid LDContext. + * + * @param kind the context kind; if null, {@link ContextKind#DEFAULT} is used + * @param key the context key + * @return a builder + * @see #builder(String) + * @see #multiBuilder() + * @see #create(ContextKind, String) + */ + public static ContextBuilder builder(ContextKind kind, String key) { + return new ContextBuilder(kind, key); + } + + /** + * Creates a builder whose properties are the same as an existing single-kind LDContext. + *

    + * You may then change the builder's state in any way and call {@link ContextBuilder#build()} + * to create a new independent LDContext. + * + * @param context the context to copy from + * @return a builder + * @see #builder(String) + */ + public static ContextBuilder builderFromContext(LDContext context) { + return new ContextBuilder().copyFrom(context); + } + + /** + * Creates a {@link ContextMultiBuilder} for building a multi-kind context. + *

    + * This method is for building a Context that has multiple {@link ContextKind} values, + * each with its own nested LDContext. To define a single-kind context, use + * {@link #builder(String)} instead. + * + * @return a builder + * @see #createMulti(LDContext...) + */ + public static ContextMultiBuilder multiBuilder() { + return new ContextMultiBuilder(); + } + + /** + * Returns {@code true} for a valid LDContext, {@code false} for an invalid one. + *

    + * A valid context is one that can be used in SDK operations. An invalid context is one that + * is missing necessary attributes or has invalid attributes, indicating an incorrect usage + * of the SDK API. The only ways for a context to be invalid are: + *

      + *
    • It has a disallowed value for the {@code kind} property. See {@link ContextKind}.
    • + *
    • It is a single-kind context whose {@code key} is empty.
    • + *
    • It is a multi-kind context that does not have any kinds. See {@link #createMulti(LDContext...)}.
    • + *
    • It is a multi-kind context where the same kind appears more than once.
    • + *
    • It is a multi-kind context where at least one of the nested LDContexts has an error.
    • + *
    + *

    + * In any of these cases, {@link #isValid()} will return false, and {@link #getError()} + * will return a description of the error. + *

    + * Since in normal usage it is easy for applications to be sure they are using context kinds + * correctly, and because throwing an exception is undesirable in application code that uses + * LaunchDarkly, the SDK stores the error state in the LDContext itself and checks for such + * errors at the time the Context is used, such as in a flag evaluation. At that point, if + * the context is invalid, the operation will fail in some well-defined way as described in + * the documentation for that method, and the SDK will generally log a warning as well. But + * in any situation where you are not sure if you have a valid LDContext, you can check + * {@link #isValid()} or {@link #getError()}. + * + * @return true if the context is valid + * @see #getError() + */ + public boolean isValid() { + return error == null; + } + + /** + * Returns null for a valid LDContext, or an error message for an invalid one. + *

    + * If this is null, then {@link #isValid()} is true. If it is non-null, then {@link #isValid()} + * is false. + * + * @return an error description or null + * @see #isValid() + */ + public String getError() { + return error; + } + + /** + * Returns the context's {@code kind} attribute. + *

    + * Every valid context has a non-empty {@link ContextKind}. For multi-kind contexts, this value + * is {@link ContextKind#MULTI} and the kinds within the context can be inspected with + * {@link #getIndividualContext(int)} or {@link #getIndividualContext(String)}. + * + * @return the context kind + * @see ContextBuilder#kind(ContextKind) + */ + public ContextKind getKind() { + return kind; + } + + /** + * Returns true if this is a multi-kind context. + *

    + * If this value is true, then {@link #getKind()} is guaranteed to be + * {@link ContextKind#MULTI}, and you can inspect the individual contexts for each kind + * with {@link #getIndividualContext(int)} or {@link #getIndividualContext(ContextKind)}. + *

    + * If this value is false, then {@link #getKind()} is guaranteed to return a value that + * is not {@link ContextKind#MULTI}. + * + * @return true for a multi-kind context, false for a single-kind context + */ + public boolean isMultiple() { + return multiContexts != null; + } + + /** + * Returns the context's {@code key} attribute. + *

    + * For a single-kind context, this value is set by one of the LDContext factory methods + * or builders ({@link #create(String)}, {@link #create(ContextKind, String)}, + * {@link #builder(String)}, {@link #builder(ContextKind, String)}). + *

    + * For a multi-kind context, there is no single value and {@link #getKey()} returns an + * empty string. Use {@link #getIndividualContext(int)} or {@link #getIndividualContext(String)} + * to inspect the LDContext for a particular kind, then call {@link #getKey()} on it. + *

    + * This value is never null. + * + * @return the context key + * @see ContextBuilder#key(String) + */ + public String getKey() { + return key; + } + + /** + * Returns the context's {@code name} attribute. + *

    + * For a single-kind context, this value is set by {@link ContextBuilder#name(String)}. + * It is null if no value was set. + *

    + * For a multi-kind context, there is no single value and {@link #getName()} returns null. + * Use {@link #getIndividualContext(int)} or {@link #getIndividualContext(String)} to + * inspect the LDContext for a particular kind, then call {@link #getName()} on it. + * + * @return the context name or null + * @see ContextBuilder#name(String) + */ + public String getName() { + return name; + } + + /** + * Returns true if this context is only intended for flag evaluations and will not be + * indexed by LaunchDarkly. + *

    + * The default value is false. False means that this LDContext represents an entity + * such as a user that you want to be able to see on the LaunchDarkly dashboard. + *

    + * Setting {@code anonymous} to true excludes this context from the database that is + * used by the dashboard. It does not exclude it from analytics event data, so it is + * not the same as making attributes private; all non-private attributes will still be + * included in events and data export. There is no limitation on what other attributes + * may be included (so, for instance, {@code anonymous} does not mean there is no + * {@code name}), and the context will still have whatever {@code key} you have given it. + *

    + * This value is also addressable in evaluations as the attribute name "anonymous". It + * is always treated as a boolean true or false in evaluations. + * + * @return true if the context should be excluded from the LaunchDarkly database + * @see ContextBuilder#anonymous(boolean) + */ + public boolean isAnonymous() { + return anonymous; + } + + /** + * Returns the context's optional secondary key attribute. + *

    + * For a single-kind context, this value is set by {@link ContextBuilder#secondary(String)}. + * It is null if no value was set. + *

    + * For a multi-kind context, there is no single value and {@link #getSecondary()} returns null. + * Use {@link #getIndividualContext(int)} or {@link #getIndividualContext(String)} to + * inspect the LDContext for a particular kind, then call {@link #getSecondary()} on it. + * + * @return the secondary key or null + * @see ContextBuilder#secondary(String) + */ + public String getSecondary() { + return secondary; + } + + /** + * Looks up the value of any attribute of the context by name. + *

    + * This includes only attributes that are addressable in evaluations-- not metadata such + * as {@link #getSecondary()}. + *

    + * For a single-kind context, the attribute name can be any custom attribute that was set + * by methods like {@link ContextBuilder#set(String, boolean)}. It can also be one of the + * built-in ones like "kind", "key", or "name"; in such cases, it is equivalent to + * {@link #getKind()}, {@link #getKey()}, or {@link #getName()}, except that the value is + * returned using the general-purpose {@link LDValue} type. + *

    + * For a multi-kind context, the only supported attribute name is "kind". Use + * {@link #getIndividualContext(int)} or {@link #getIndividualContext(ContextKind)} to + * inspect the LDContext for a particular kind and then get its attributes. + *

    + * This method does not support complex expressions for getting individual values out of + * JSON objects or arrays, such as "/address/street". Use {@link #getValue(AttributeRef)} + * with an {@link AttributeRef} for that purpose. + *

    + * If the value is found, the return value is the attribute value, using the type + * {@link LDValue} to represent a value of any JSON type. + *

    + * If there is no such attribute, the return value is {@link LDValue#ofNull()} (the method + * never returns a Java {@code null}). An attribute that actually exists cannot have a null + * value. + * + * @param attributeName the desired attribute name + * @return the value or {@link LDValue#ofNull()} + * @see #getValue(AttributeRef) + * @see ContextBuilder#set(String, String) + */ + public LDValue getValue(String attributeName) { + return getTopLevelAttribute(attributeName); + } + + /** + * Looks up the value of any attribute of the context, or a value contained within an + * attribute, based on an {@link AttributeRef}. + *

    + * This includes only attributes that are addressable in evaluations-- not metadata such + * as {@link #getSecondary()}. + *

    + * This implements the same behavior that the SDK uses to resolve attribute references + * during a flag evaluation. In a single-kind context, the {@link AttributeRef} can + * represent a simple attribute name-- either a built-in one like "name" or "key", or a + * custom attribute that was set by methods like {@link ContextBuilder#set(String, String)}-- + * or, it can be a slash-delimited path using a JSON-Pointer-like syntax. See + * {@link AttributeRef} for more details. + *

    + * For a multi-kind context, the only supported attribute name is "kind". Use + * {@link #getIndividualContext(int)} or {@link #getIndividualContext(ContextKind)} to + * inspect the LDContext for a particular kind and then get its attributes. + *

    + * This method does not support complex expressions for getting individual values out of + * JSON objects or arrays, such as "/address/street". Use {@link #getValue(AttributeRef)} + * with an {@link AttributeRef} for that purpose. + *

    + * If the value is found, the return value is the attribute value, using the type + * {@link LDValue} to represent a value of any JSON type. + *

    + * If there is no such attribute, the return value is {@link LDValue#ofNull()} (the method + * never returns a Java {@code null}). An attribute that actually exists cannot have a null + * value. + * @param attributeRef an attribute reference + * @return the attribute value + */ + public LDValue getValue(AttributeRef attributeRef) { + if (attributeRef == null || !attributeRef.isValid()) { + return LDValue.ofNull(); + } + + String name = attributeRef.getComponent(0); + + if (isMultiple()) { + if (attributeRef.getDepth() == 1 && name.equals("kind")) { + return LDValue.of(kind.toString()); + } + return LDValue.ofNull(); // multi-kind context has no other addressable attributes + } + + // Look up attribute in single-kind context + LDValue value = getTopLevelAttribute(name); + if (value.isNull()) { + return value; + } + for (int i = 1; i < attributeRef.getDepth(); i++) { + String component = attributeRef.getComponent(i); + Integer asInt = attributeRef.getComponentAsInteger(i); + if (asInt != null && value.getType() == LDValueType.ARRAY) { + value = value.get(asInt.intValue()); + } else { + value = value.get(component); + } + if (value.isNull()) { + break; + } + } + return value; + } + + /** + * Returns the names of all non-built-in attributes that have been set in this context. + *

    + * For a single-kind context, this includes all the names that were passed to + * any of the overloads of {@link ContextBuilder#set(String, LDValue)} as long as the + * values were not null (since a null value in LaunchDarkly is equivalent to the attribute + * not being set). + *

    + * For a multi-kind context, there are no such names. + * + * @return an iterable of strings (may be empty, but will never be null) + */ + public Iterable getCustomAttributeNames() { + return attributes == null ? Collections.emptyList() : attributes.keySet(); + } + + /** + * Returns the number of context kinds in this context. + *

    + * For a valid single-kind context, this returns 1. For a multi-kind context, it returns + * the number of kinds that were added with {@link #createMulti(LDContext...)} or + * {@link #multiBuilder()}. For an invalid context, it returns zero. + * + * @return the number of context kinds + */ + public int getIndividualContextCount() { + if (error != null) { + return 0; + } + return multiContexts == null ? 1 : multiContexts.size(); + } + + /** + * Returns the single-kind LDContext corresponding to one of the kinds in this context. + *

    + * If this method is called on a single-kind LDContext, then the only allowable value + * for {@code index} is zero, and the return value on success is the same LDContext. If + * the method is called on a multi-kind context, then index must be non-negative and + * less than the number of kinds (that is, less than the return value of + * {@link #getIndividualContextCount()}), and the return value on success is one of the + * individual LDContexts within. + * + * @param index the zero-based index of the context to get + * @return an {@link LDContext}, or null if the index was out of range + */ + public LDContext getIndividualContext(int index) { + if (multiContexts == null) { + return index == 0 ? this : null; + } + return index < 0 || index >= multiContexts.size() ? null : multiContexts.get(index); + } + + /** + * Returns the single-kind LDContext corresponding to one of the kinds in this context. + *

    + * If this method is called on a single-kind LDContext, then the only allowable value + * for {@code kind} is the same as {@link #getKind()}, and the return value on success + * is the same LDContext. If the method is called on a multi-kind context, then + * {@code kind} should be match the kind of one of the contexts that was added with + * {@link #createMulti(LDContext...)} or {@link #multiBuilder()}, and the return value on + * success is the corresponding individual LDContext within. + * + * @param kind the context kind to get + * @return an {@link LDContext}, or null if that kind was not found + */ + public LDContext getIndividualContext(ContextKind kind) { + if (multiContexts == null) { + return this.kind.equals(kind) ? this : null; + } + for (LDContext c: multiContexts) { + if (c.kind.equals(kind)) { + return c; + } + } + return null; + } + + /** + * Same as {@link #getIndividualContext(ContextKind)}, but specifies the kind as a + * plain string. + * + * @param kind the context kind to get + * @return an {@link LDContext}, or null if that kind was not found + */ + public LDContext getIndividualContext(String kind) { + if (kind == null || kind.isEmpty()) { + return getIndividualContext(ContextKind.DEFAULT); + } + if (multiContexts == null) { + return this.kind.toString().equals(kind) ? this : null; + } + for (LDContext c: multiContexts) { + if (c.kind.toString().equals(kind)) { + return c; + } + } + return null; + } + + /** + * Returns the number of private attribute references that were specified for this context. + *

    + * This is equal to the total number of values passed to {@link ContextBuilder#privateAttributes(String...)} + * and/or its overload {@link ContextBuilder#privateAttributes(AttributeRef...)}. + * + * @return the number of private attribute references + */ + public int getPrivateAttributeCount() { + return privateAttributes == null ? 0 : privateAttributes.size(); + } + + /** + * Retrieves one of the private attribute references that were specified for this context. + * + * @param index a non-negative index that must be less than {@link #getPrivateAttributeCount()} + * @return an {@link AttributeRef}, or null if the index was out of range + */ + public AttributeRef getPrivateAttribute(int index) { + if (privateAttributes == null) { + return null; + } + return index < 0 || index >= privateAttributes.size() ? null : privateAttributes.get(index); + } + + /** + * Returns a string that describes the LDContext uniquely based on {@code kind} and + * {@code key} values. + *

    + * This value is used whenever LaunchDarkly needs a string identifier based on all of the + * {@code kind} and {@code key} values in the context; the SDK may use this for caching + * previously seen contexts, for instance. + * + * @return the fully-qualified key + */ + public String getFullyQualifiedKey() { + return fullyQualifiedKey; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof LDContext)) { + return false; + } + LDContext o = (LDContext)other; + if (!Objects.equals(error, o.error)) { + return false; + } + if (error != null) { + return true; // there aren't any other attributes + } + if (!kind.equals(o.kind)) { + return false; + } + if (isMultiple()) { + if (multiContexts.size() != o.multiContexts.size()) { + return false; + } + for (int i = 0; i < multiContexts.size(); i++) { + if (!multiContexts.get(i).equals(o.multiContexts.get(i))) { + return false; + } + } + return true; + } + if (!key.equals(o.key) || !Objects.equals(name, o.name) || anonymous != o.anonymous || + !Objects.equals(secondary, o.secondary)) { + return false; + } + if ((attributes == null ? 0 : attributes.size()) != + (o.attributes == null ? 0 : o.attributes.size())) { + return false; + } + if (attributes != null) { + for (Map.Entry kv: attributes.entrySet()) { + if (!Objects.equals(o.attributes.get(kv.getKey()), kv.getValue())) { + return false; + } + } + } + if (getPrivateAttributeCount() != o.getPrivateAttributeCount()) { + return false; + } + if (privateAttributes != null) { + for (AttributeRef a: privateAttributes) { + boolean found = false; + for (AttributeRef a1: o.privateAttributes) { + if (a1.equals(a)) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + } + return true; + } + + @Override + public int hashCode() { + // This implementation of hashCode() is inefficient due to the need to create a predictable ordering + // of attribute names. That's necessary just for the sake of aligning with the behavior of equals(), + // which is insensitive to ordering. However, using an LDContext as a map key is not an anticipated + // or recommended use case. + int h = Objects.hash(error, kind, key, name, anonymous, secondary); + if (multiContexts != null) { + for (LDContext c: multiContexts) { + h = h * 17 + c.hashCode(); + } + } + if (attributes != null) { + String[] names = attributes.keySet().toArray(new String[attributes.size()]); + Arrays.sort(names); + for (String name: names) { + h = (h * 17 + name.hashCode()) * 17 + attributes.get(name).hashCode(); + } + } + if (privateAttributes != null) { + AttributeRef[] refs = privateAttributes.toArray(new AttributeRef[privateAttributes.size()]); + Arrays.sort(refs);; + for (AttributeRef a: refs) { + h = h * 17 + a.hashCode(); + } + } + return h; + } + + private LDValue getTopLevelAttribute(String attributeName) { + switch (attributeName) { + case "kind": + return LDValue.of(kind.toString()); + case "key": + return multiContexts == null ? LDValue.of(key) : LDValue.ofNull(); + case "name": + return LDValue.of(name); + case "anonymous": + return LDValue.of(anonymous); + default: + if (attributes == null) { + return LDValue.ofNull(); + } + LDValue v = attributes.get(attributeName); + return v == null ? LDValue.ofNull() : v; + } + } + + private static String urlEncodeKey(String key) { + try { + return URLEncoder.encode(key, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return ""; // COVERAGE: not a reachable condition + } + } + + private static class ByKindComparator implements Comparator { + static final ByKindComparator INSTNACE = new ByKindComparator(); + + public int compare(LDContext c1, LDContext c2) { + return c1.getKind().toString().compareTo(c2.getKind().toString()); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index bf36572..5c8be85 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -1,6 +1,5 @@ package com.launchdarkly.sdk; -import com.google.gson.Gson; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonWriter; import com.launchdarkly.sdk.json.JsonSerializable; diff --git a/src/test/java/com/launchdarkly/sdk/ContextBuilderTest.java b/src/test/java/com/launchdarkly/sdk/ContextBuilderTest.java new file mode 100644 index 0000000..6400b03 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/ContextBuilderTest.java @@ -0,0 +1,138 @@ +package com.launchdarkly.sdk; + +import org.junit.Test; + +import java.util.List; + +import static com.launchdarkly.sdk.LDContextTest.kind1; +import static com.launchdarkly.sdk.LDContextTest.kind2; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +@SuppressWarnings("javadoc") +public class ContextBuilderTest { + @Test + public void setValueTypes() { + assertThat(LDContext.builder("key").set("a", true).build().getValue("a"), equalTo(LDValue.of(true))); + assertThat(LDContext.builder("key").set("a", 1).build().getValue("a"), equalTo(LDValue.of(1))); + assertThat(LDContext.builder("key").set("a", 1.5).build().getValue("a"), equalTo(LDValue.of(1.5))); + assertThat(LDContext.builder("key").set("a", "b").build().getValue("a"), equalTo(LDValue.of("b"))); + } + + @Test + public void setValueToNullRemovesAttribute() { + assertThat(LDContext.builder("key").set("a", true).set("a", LDValue.ofNull()) + .build().getCustomAttributeNames(), emptyIterable()); + assertThat(LDContext.builder("key").set("a", true).set("a", (LDValue)null) + .build().getCustomAttributeNames(), emptyIterable()); + } + + @Test + public void setValueCanSetBuiltInPropertiesToValidValueType() { + assertThat(LDContext.builder(kind1, "key").set("kind", kind2.toString()).build().getKind(), equalTo(kind2)); + assertThat(LDContext.builder("key").set("key", "a").build().getKey(), equalTo("a")); + assertThat(LDContext.builder("key").name("x").set("name", "a").build().getName(), equalTo("a")); + assertThat(LDContext.builder("key").name("x").set("name", LDValue.ofNull()).build().getName(), nullValue()); + assertThat(LDContext.builder("key").set("anonymous", true).build().isAnonymous(), is(true)); + assertThat(LDContext.builder("key").anonymous(true).set("anonymous", false).build().isAnonymous(), is(false)); + } + + @Test + public void setValueCannotSetMetaProperties() { + LDContext c1 = LDContext.builder("key").set("secondary", "x").build(); + assertThat(c1.getSecondary(), nullValue()); + assertThat(c1.getValue("secondary"), equalTo(LDValue.of("x"))); + + LDContext c2 = LDContext.builder("key").set("privateAttributes", "x").build(); + assertThat(c2.getPrivateAttributeCount(), equalTo(0)); + assertThat(c2.getValue("privateAttributes"), equalTo(LDValue.of("x"))); + } + + @Test + public void setValueIgnoresInvalidNamesAndInvalidValueTypes() { + LDContext c = LDContext.builder("key").set("_meta", + LDValue.buildObject().put("secondary", "x").build()).build(); + assertThat(c.getSecondary(), nullValue()); + assertThat(c.getValue("_meta"), equalTo(LDValue.ofNull())); + + assertThat(LDContext.builder(kind1, "key").set("kind", LDValue.of(1)).build().getKind(), equalTo(kind1)); + assertThat(LDContext.builder("key").set("key", 1).build().getKey(), equalTo("key")); + assertThat(LDContext.builder("key").name("x").set("name", 1).build().getName(), equalTo("x")); + assertThat(LDContext.builder("key").anonymous(true).set("anonymous", LDValue.ofNull()).build().isAnonymous(), is(true)); + assertThat(LDContext.builder("key").set("", true).build().getCustomAttributeNames(), emptyIterable()); + assertThat(LDContext.builder("key").set(null, true).build().getCustomAttributeNames(), emptyIterable()); + } + + @Test + public void copyOnWriteAttributes() { + ContextBuilder cb = LDContext.builder("key").set("a", 1); + LDContext c1 = cb.build(); + + cb.set("a", 2).set("b", 3); + LDContext c2 = cb.build(); + + assertThat(c1.getValue("a"), equalTo(LDValue.of(1))); + assertThat(c1.getValue("b"), equalTo(LDValue.ofNull())); + assertThat(c2.getValue("a"), equalTo(LDValue.of(2))); + assertThat(c2.getValue("b"), equalTo(LDValue.of(3))); + } + + @Test + public void privateAttributes() { + LDContext c1 = LDContext.create("a"); + assertThat(c1.getPrivateAttributeCount(), equalTo(0)); + assertThat(c1.getPrivateAttribute(0), nullValue()); + assertThat(c1.getPrivateAttribute(-1), nullValue()); + + LDContext c2 = LDContext.builder("a").privateAttributes("a", "b").build(); + assertThat(c2.getPrivateAttributeCount(), equalTo(2)); + assertThat(c2.getPrivateAttribute(0), equalTo(AttributeRef.fromLiteral("a"))); + assertThat(c2.getPrivateAttribute(1), equalTo(AttributeRef.fromLiteral("b"))); + assertThat(c2.getPrivateAttribute(2), nullValue()); + assertThat(c2.getPrivateAttribute(-1), nullValue()); + + LDContext c3 = LDContext.builder("a").privateAttributes(AttributeRef.fromPath("/a"), + AttributeRef.fromPath("/a/b")).build(); + assertThat(c3.getPrivateAttributeCount(), equalTo(2)); + assertThat(c3.getPrivateAttribute(0), equalTo(AttributeRef.fromPath("/a"))); + assertThat(c3.getPrivateAttribute(1), equalTo(AttributeRef.fromPath("/a/b"))); + assertThat(c3.getPrivateAttribute(2), nullValue()); + assertThat(c3.getPrivateAttribute(-1), nullValue()); + + // no-op cases + assertThat(LDContext.builder("a").privateAttributes((String[])null).build() + .getPrivateAttributeCount(), equalTo(0)); + assertThat(LDContext.builder("a").privateAttributes((AttributeRef[])null).build() + .getPrivateAttributeCount(), equalTo(0)); + } + + @Test + public void copyOnWritePrivateAttributes() { + ContextBuilder cb = LDContext.builder("key").privateAttributes("a"); + LDContext c1 = cb.build(); + + cb.privateAttributes("b"); + LDContext c2 = cb.build(); + + assertThat(c1.getPrivateAttributeCount(), equalTo(1)); + assertThat(c2.getPrivateAttributeCount(), equalTo(2)); + } + + @Test + public void builderFromContext() { + List> values = LDContextTest.makeValues(); + for (List l: values) { + LDContext c1 = l.get(0); + if (c1.isMultiple() || !c1.isValid()) { + continue; + } + LDContext c2 = LDContext.builderFromContext(c1).build(); + if (!c2.equals(c1)) { + assertThat(c2, equalTo(c1)); + } + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/ContextKindTest.java b/src/test/java/com/launchdarkly/sdk/ContextKindTest.java new file mode 100644 index 0000000..eeebfb0 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/ContextKindTest.java @@ -0,0 +1,53 @@ +package com.launchdarkly.sdk; + +import org.hamcrest.Matchers; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static java.util.Arrays.asList; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +@SuppressWarnings("javadoc") +public class ContextKindTest { + @Test + public void nonEmptyValue() { + assertThat(ContextKind.of("abc").toString(), equalTo("abc")); + } + + @Test + public void nullOrEmptyBecomesDefault() { + assertThat(ContextKind.of(null).toString(), equalTo("user")); + assertThat(ContextKind.of("").toString(), equalTo("user")); + } + + @Test + public void predefinedValuesAreInterned() { + assertThat(ContextKind.of("user"), Matchers.sameInstance(ContextKind.DEFAULT)); + assertThat(ContextKind.of("multi"), Matchers.sameInstance(ContextKind.MULTI)); + } + + @Test + public void isDefault() { + assertThat(ContextKind.of("abc").isDefault(), is(false)); + assertThat(ContextKind.of("user").isDefault(), is(true)); + assertThat(ContextKind.DEFAULT.isDefault(), is(true)); + } + + @Test + public void equality() { + List> testValues = new ArrayList<>(); + for (ContextKind kind: new ContextKind[] { ContextKind.DEFAULT, ContextKind.of("A"), ContextKind.of("a"), ContextKind.of("b") }) { + testValues.add(asList(kind, kind)); + } + TestHelpers.doEqualityTests(testValues); + } + + @Test + public void testHashCode() { + assertThat(ContextKind.of("abc").hashCode(), equalTo("abc".hashCode())); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java b/src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java new file mode 100644 index 0000000..77e02eb --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java @@ -0,0 +1,74 @@ +package com.launchdarkly.sdk; + +import org.junit.Test; + +import static com.launchdarkly.sdk.LDContextTest.invalidKindThatIsLiterallyKind; +import static com.launchdarkly.sdk.LDContextTest.kind1; +import static com.launchdarkly.sdk.LDContextTest.kind2; +import static com.launchdarkly.sdk.LDContextTest.kind3; +import static com.launchdarkly.sdk.LDContextTest.shouldBeInvalid; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.sameInstance; + +@SuppressWarnings("javadoc") +public class ContextMultiBuilderTest { + @Test + public void builderIsEquivalentToConstructor() { + LDContext c1 = LDContext.create(kind1, "key1"); + LDContext c2 = LDContext.create(kind2, "key2"); + + assertThat(LDContext.createMulti(c1, c2), + equalTo(LDContext.multiBuilder().add(c1).add(c2).build())); + } + + @Test + public void builderWithOneKindReturnsSingleKindContext() { + LDContext c1 = LDContext.create("key"); + LDContext c2 = LDContext.multiBuilder().add(c1).build(); + assertThat(c2, sameInstance(c1)); + } + + @Test + public void builderValidationErrors() { + shouldBeInvalid( + LDContext.multiBuilder().build(), + Errors.CONTEXT_KIND_MULTI_WITH_NO_KINDS); + + shouldBeInvalid( + LDContext.multiBuilder() + .add(LDContext.create("key1")) + .add(LDContext.createMulti(LDContext.create(kind1, "key2"), LDContext.create(kind2, "key3"))) + .build(), + Errors.CONTEXT_KIND_MULTI_WITHIN_MULTI); + + shouldBeInvalid( + LDContext.multiBuilder() + .add(LDContext.create(kind1, "key1")) + .add(LDContext.create(kind1, "key2")) + .build(), + Errors.CONTEXT_KIND_MULTI_DUPLICATES); + + shouldBeInvalid( + LDContext.multiBuilder() + .add(LDContext.create("")) + .add(LDContext.create(invalidKindThatIsLiterallyKind, "key")) + .build(), + Errors.CONTEXT_NO_KEY + ", " + Errors.CONTEXT_KIND_CANNOT_BE_KIND); + } + + @Test + public void copyOnWrite() { + LDContext c1 = LDContext.create(kind1, "key1"), + c2 = LDContext.create(kind2, "key2"), + c3 = LDContext.create(kind3, "key3"); + + ContextMultiBuilder mb = LDContext.multiBuilder(); + mb.add(c1).add(c2); + LDContext mc1 = mb.build(); + mb.add(c3); + LDContext mc2 = mb.build(); + assertThat(mc1.getIndividualContextCount(), equalTo(2)); + assertThat(mc2.getIndividualContextCount(), equalTo(3)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/LDContextTest.java b/src/test/java/com/launchdarkly/sdk/LDContextTest.java new file mode 100644 index 0000000..8f6948c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/LDContextTest.java @@ -0,0 +1,386 @@ +package com.launchdarkly.sdk; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static java.util.Arrays.asList; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.sameInstance; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class LDContextTest { + static final ContextKind + kind1 = ContextKind.of("kind1"), + kind2 = ContextKind.of("kind2"), + kind3 = ContextKind.of("kind3"), + invalidKindThatIsLiterallyKind = ContextKind.of("kind"), + invalidKindWithDisallowedChar = ContextKind.of("ørg"); + + @Test + public void singleKindConstructors() { + LDContext c1 = LDContext.create("x"); + assertThat(c1.getKind(), equalTo(ContextKind.DEFAULT)); + assertThat(c1.getKey(), equalTo("x")); + assertThat(c1.getName(), nullValue()); + assertThat(c1.isAnonymous(), is(false)); + assertThat(c1.getSecondary(), nullValue()); + assertThat(c1.getCustomAttributeNames(), emptyIterable()); + + LDContext c2 = LDContext.create(kind1, "x"); + assertThat(c2.getKind(), equalTo(kind1)); + assertThat(c2.getKey(), equalTo("x")); + assertThat(c2.getName(), nullValue()); + assertThat(c2.isAnonymous(), is(false)); + assertThat(c2.getSecondary(), nullValue()); + assertThat(c2.getCustomAttributeNames(), emptyIterable()); + } + + @Test + public void singleKindBuilderProperties() { + assertThat(LDContext.builder(".").kind(kind1).build().getKind(), equalTo(kind1)); + assertThat(LDContext.builder(".").key("x").build().getKey(), equalTo("x")); + assertThat(LDContext.builder(".").name("x").build().getName(), equalTo("x")); + assertThat(LDContext.builder(".").name("x").name(null).build().getName(), nullValue()); + assertThat(LDContext.builder(".").anonymous(true).build().isAnonymous(), is(true)); + assertThat(LDContext.builder(".").anonymous(true).anonymous(false).build().isAnonymous(), is(false)); + assertThat(LDContext.builder(".").set("a", "x").build().getValue("a"), equalTo(LDValue.of("x"))); + assertThat(LDContext.builder(".").secondary("x").build().getSecondary(), equalTo("x")); + } + + @Test + public void invalidContexts() { + shouldBeInvalid(LDContext.create(null), Errors.CONTEXT_NO_KEY); + shouldBeInvalid(LDContext.create(""), Errors.CONTEXT_NO_KEY); + shouldBeInvalid(LDContext.create(invalidKindThatIsLiterallyKind, "key"), + Errors.CONTEXT_KIND_CANNOT_BE_KIND); + shouldBeInvalid(LDContext.create(invalidKindWithDisallowedChar, "key"), + Errors.CONTEXT_KIND_INVALID_CHARS); + + shouldBeInvalid(LDContext.create(ContextKind.MULTI, "key"), Errors.CONTEXT_KIND_MULTI_FOR_SINGLE); + + shouldBeInvalid(LDContext.createMulti(), Errors.CONTEXT_KIND_MULTI_WITH_NO_KINDS); + + shouldBeInvalid( + LDContext.createMulti( + LDContext.create("key1"), + LDContext.createMulti(LDContext.create(kind1, "key2"), LDContext.create(kind2, "key3")) + ), + Errors.CONTEXT_KIND_MULTI_WITHIN_MULTI); + + shouldBeInvalid( + LDContext.createMulti( + LDContext.create(kind1, "key1"), + LDContext.create(kind1, "key2") + ), + Errors.CONTEXT_KIND_MULTI_DUPLICATES); + + shouldBeInvalid( + LDContext.createMulti( + LDContext.create(""), + LDContext.create(invalidKindThatIsLiterallyKind, "key") + ), + Errors.CONTEXT_NO_KEY + ", " + Errors.CONTEXT_KIND_CANNOT_BE_KIND); + } + + static void shouldBeInvalid(LDContext c, String expectedError) { + assertThat(c.isValid(), is(false)); + assertThat(c.getError(), equalTo(expectedError)); + + // we guarantee that key is non-null even for invalid contexts, just to reduce risk of NPEs + assertThat(c.getKey(), equalTo("")); + } + + @Test + public void multiple() { + assertThat(LDContext.create("my-key").isMultiple(), is(false)); + assertThat(LDContext.createMulti(LDContext.create(kind1, "key1"), LDContext.create(kind2, "key2")).isMultiple(), + is(true)); + } + + @Test + public void fullyQualifiedKey() { + assertThat(LDContext.create("abc").getFullyQualifiedKey(), equalTo("abc")); + assertThat(LDContext.create("abc:d").getFullyQualifiedKey(), equalTo("abc:d")); + assertThat(LDContext.create(kind1, "key1").getFullyQualifiedKey(), equalTo("kind1:key1")); + assertThat(LDContext.create(kind1, "key:2").getFullyQualifiedKey(), equalTo("kind1:key%3A2")); + assertThat( + LDContext.createMulti(LDContext.create(kind1, "key1"), LDContext.create(kind2, "key:2")).getFullyQualifiedKey(), + equalTo("kind1:key1:kind2:key%3A2")); + } + + @Test + public void customAttributeNames() { + assertThat(LDContext.create("a").getCustomAttributeNames(), emptyIterable()); + + assertThat(LDContext.builder("a").name("b").build().getCustomAttributeNames(), emptyIterable()); + + assertThat(LDContext.builder("a").set("email", "b").set("happy", true).build().getCustomAttributeNames(), + containsInAnyOrder("email", "happy")); + + // meta-attributes and non-optional attributes are not included + assertThat(LDContext.builder("a").secondary("b").anonymous(true).build().getCustomAttributeNames(), + emptyIterable()); + + // none for multi-kind context + assertThat( + LDContext.createMulti( + LDContext.builder(kind1, "key1").set("a", "b").build(), + LDContext.builder(kind2, "key2").set("a", "b").build() + ).getCustomAttributeNames(), + emptyIterable()); + } + + @Test + public void getValue() { + LDContext c = LDContext.builder("my-key").kind("org").name("x") + .set("my-attr", "y").set("/starts-with-slash", "z").build(); + + expectAttributeFoundForName(LDValue.of("org"), c, "kind"); + expectAttributeFoundForName(LDValue.of("my-key"), c, "key"); + expectAttributeFoundForName(LDValue.of("x"), c, "name"); + expectAttributeFoundForName(LDValue.of("y"), c, "my-attr"); + expectAttributeFoundForName(LDValue.of("z"), c, "/starts-with-slash"); + + expectAttributeNotFoundForName(c, "/kind"); + expectAttributeNotFoundForName(c, "/key"); + expectAttributeNotFoundForName(c, "/name"); + expectAttributeNotFoundForName(c, "/my-attr"); + expectAttributeNotFoundForName(c, "other"); + expectAttributeNotFoundForName(c, ""); + expectAttributeNotFoundForName(c, "/"); + + LDContext mc = LDContext.createMulti(c, LDContext.create(ContextKind.of("otherkind"), "otherkey")); + + expectAttributeFoundForName(LDValue.of("multi"), mc, "kind"); + + expectAttributeNotFoundForName(mc, "/kind"); + expectAttributeNotFoundForName(mc, "key"); + + // does not allow querying of subpath/element + LDValue objValue = LDValue.buildObject().put("a", 1).build(); + LDValue arrayValue = LDValue.arrayOf(LDValue.of(1)); + LDContext c1 = LDContext.builder("key").set("obj-attr", objValue).set("array-attr", arrayValue).build(); + expectAttributeFoundForName(objValue, c1, "obj-attr"); + expectAttributeFoundForName(arrayValue, c1, "array-attr"); + expectAttributeNotFoundForName(c1, "/obj-attr/a"); + expectAttributeNotFoundForName(c1, "/array-attr/0"); + } + + private static void expectAttributeFoundForName(LDValue expectedValue, LDContext c, String name) { + LDValue value = c.getValue(name); + if (value.isNull()) { + fail(String.format("attribute \"%s\" should have been found, but was not", name)); + } + assertThat(value, equalTo(expectedValue)); + } + + private static void expectAttributeNotFoundForName(LDContext c, String name) { + LDValue value = c.getValue(name); + if (!value.isNull()) { + fail(String.format("attribute \"%s\" should not have been found, but was", name)); + } + } + + @Test + public void getValueForRefSpecialTopLevelAttributes() { + LDContext multi = LDContext.createMulti( + LDContext.create("my-key"), LDContext.create(ContextKind.of("otherkind"), "otherkey")); + + expectAttributeFoundForRef(LDValue.of("org"), LDContext.create(ContextKind.of("org"), "my-key"), "kind"); + expectAttributeFoundForRef(LDValue.of("multi"), multi, "kind"); + + expectAttributeFoundForRef(LDValue.of("my-key"), LDContext.create("my-key"), "key"); + expectAttributeNotFoundForRef(multi, "key"); + + expectAttributeFoundForRef(LDValue.of("my-name"), LDContext.builder("key").name("my-name").build(), "name"); + expectAttributeNotFoundForRef(LDContext.create("key"), "name"); + expectAttributeNotFoundForRef(multi, "name"); + + expectAttributeFoundForRef(LDValue.of(false), LDContext.create("key"), "anonymous"); + expectAttributeFoundForRef(LDValue.of(true), LDContext.builder("key").anonymous(true).build(), "anonymous"); + expectAttributeNotFoundForRef(multi, "anonymous"); + } + + private static void expectAttributeFoundForRef(LDValue expectedValue, LDContext c, String ref) { + LDValue value = c.getValue(AttributeRef.fromPath(ref)); + if (value.isNull()) { + fail(String.format("attribute \"{}\" should have been found, but was not", ref)); + } + assertThat(value, equalTo(expectedValue)); + } + + private static void expectAttributeNotFoundForRef(LDContext c, String ref) { + LDValue value = c.getValue(AttributeRef.fromPath(ref)); + if (!value.isNull()) { + fail(String.format("attribute \"{}\" should not have been found, but was", ref)); + } + } + + @Test + public void getValueForRefCannotGetMetaProperties() { + expectAttributeNotFoundForRef(LDContext.builder("key").privateAttributes("attr").build(), "privateAttributes"); + expectAttributeNotFoundForRef(LDContext.builder("key").secondary("my-value").build(), "secondary"); + } + + @Test + public void getValueForRefCustomAttributeSingleKind() { + // simple attribute name + expectAttributeFoundForRef(LDValue.of("abc"), + LDContext.builder("key").set("my-attr", "abc").build(), "my-attr"); + + // simple attribute name not found + expectAttributeNotFoundForRef(LDContext.create("key"), "my-attr"); + expectAttributeNotFoundForRef(LDContext.builder("key").set("other-attr", "abc").build(), "my-attr"); + + // property in object + expectAttributeFoundForRef(LDValue.of("abc"), + LDContext.builder("key").set("my-attr", LDValue.parse("{\"my-prop\":\"abc\"}")).build(), + "/my-attr/my-prop"); + + // property in object not found + expectAttributeNotFoundForRef( + LDContext.builder("key").set("my-attr", LDValue.parse("{\"my-prop\":\"abc\"}")).build(), + "/my-attr/other-prop"); + + // property in nested object + expectAttributeFoundForRef(LDValue.of("abc"), + LDContext.builder("key").set("my-attr", LDValue.parse("{\"my-prop\":{\"sub-prop\":\"abc\"}}")).build(), + "/my-attr/my-prop/sub-prop"); + + // property in value that is not an object + expectAttributeNotFoundForRef( + LDContext.builder("key").set("my-attr", "xyz").build(), + "/my-attr/my-prop"); + + // element in array + expectAttributeFoundForRef(LDValue.of("good"), + LDContext.builder("key").set("my-attr", LDValue.parse("[\"bad\",\"good\",\"worse\"]")).build(), + "/my-attr/1"); + + // element in nested array in object + expectAttributeFoundForRef(LDValue.of("good"), + LDContext.builder("key").set("my-attr", LDValue.parse("{\"my-prop\":[\"bad\",\"good\",\"worse\"]}")).build(), + "/my-attr/my-prop/1"); + + // index too low in array + expectAttributeNotFoundForRef( + LDContext.builder("key").set("my-attr", LDValue.parse("[\"bad\",\"good\",\"worse\"]")).build(), + "/my-attr/-1"); + + // index too high in array + expectAttributeNotFoundForRef( + LDContext.builder("key").set("my-attr", LDValue.parse("[\"bad\",\"good\",\"worse\"]")).build(), + "/my-attr/3"); + + // index in value that is not an array + expectAttributeNotFoundForRef( + LDContext.builder("key").set("my-attr", "xyz").build(), + "/my-attr/0"); + } + + @Test + public void getValueForInvalidRef() { + expectAttributeNotFoundForRef(LDContext.create("key"), "/"); + } + + @Test + public void multiKindContexts() { + LDContext c1 = LDContext.create(kind1, "key1"); + LDContext c2 = LDContext.create(kind2, "key2"); + LDContext multi = LDContext.createMulti(c1, c2); + + assertThat(c1.getIndividualContextCount(), equalTo(1)); + assertThat(c1.getIndividualContext(0), sameInstance(c1)); + assertThat(c1.getIndividualContext(1), nullValue()); + assertThat(c1.getIndividualContext(-1), nullValue()); + assertThat(c1.getIndividualContext(kind1), sameInstance(c1)); + assertThat(c1.getIndividualContext(kind1.toString()), sameInstance(c1)); + assertThat(c1.getIndividualContext(kind2), nullValue()); + assertThat(c1.getIndividualContext(kind2.toString()), nullValue()); + + assertThat(multi.getIndividualContextCount(), equalTo(2)); + assertThat(multi.getIndividualContext(0), sameInstance(c1)); + assertThat(multi.getIndividualContext(1), sameInstance(c2)); + assertThat(multi.getIndividualContext(2), nullValue()); + assertThat(multi.getIndividualContext(-1), nullValue()); + assertThat(multi.getIndividualContext(kind1), sameInstance(c1)); + assertThat(multi.getIndividualContext(kind1.toString()), sameInstance(c1)); + assertThat(multi.getIndividualContext(kind2), sameInstance(c2)); + assertThat(multi.getIndividualContext(kind2.toString()), sameInstance(c2)); + assertThat(multi.getIndividualContext(kind3), nullValue()); + assertThat(multi.getIndividualContext(kind3.toString()), nullValue()); + + assertThat(LDContext.createMulti(c1), sameInstance(c1)); + + LDContext uc1 = LDContext.create("key1"); + LDContext multi2 = LDContext.createMulti(uc1, c2); + assertThat(multi2.getIndividualContext(ContextKind.DEFAULT), sameInstance(uc1)); + assertThat(multi2.getIndividualContext(""), sameInstance(uc1)); + + LDContext invalid = LDContext.create(""); + assertThat(invalid.getIndividualContextCount(), equalTo(0)); + } + + @Test + public void equality() { + List> values = makeValues(); + TestHelpers.doEqualityTests(values); + } + + static List> makeValues() { + // This awkward pattern of creating every value twice is due to how our current + // TestHelpers.doEqualityTests() works. When we are able to migrate to using the + // similar method in java-test-helpers, we can use a single lambda for each instead. + List> values = new ArrayList<>(); + + values.add(asList(LDContext.create("a"), LDContext.create("a"))); + values.add(asList(LDContext.create("b"), LDContext.create("b"))); + + values.add(asList(LDContext.create(kind1, "a"), LDContext.create(kind1, "a"))); + values.add(asList(LDContext.create(kind1, "b"), LDContext.create(kind1, "b"))); + + values.add(asList(LDContext.builder("a").name("b").build(), LDContext.builder("a").name("b").build())); + + values.add(asList(LDContext.builder("a").secondary("b").build(), LDContext.builder("a").secondary("b").build())); + values.add(asList(LDContext.builder("a").secondary("").build(), LDContext.builder("a").secondary("").build())); + + values.add(asList(LDContext.builder("a").anonymous(true).build(), LDContext.builder("a").anonymous(true).build())); + + values.add(asList(LDContext.builder("a").set("b", true).build(), LDContext.builder("a").set("b", true).build())); + values.add(asList(LDContext.builder("a").set("b", false).build(), LDContext.builder("a").set("b", false).build())); + + values.add(asList(LDContext.builder("a").set("b", true).set("c", false).build(), + LDContext.builder("a").set("c", false).set("b", true).build())); // ordering of attributes doesn't matter + + values.add(asList(LDContext.builder("a").privateAttributes("b").build(), + LDContext.builder("a").privateAttributes("b").build())); + values.add(asList(LDContext.builder("a").privateAttributes("b", "c").build(), + LDContext.builder("a").privateAttributes("c", "b").build())); // ordering of private attributes doesn't matter + values.add(asList(LDContext.builder("a").privateAttributes("b", "d").build(), + LDContext.builder("a").privateAttributes("b", "d").build())); + + values.add(asList( + LDContext.createMulti(LDContext.create(kind1, "a"), LDContext.create(kind2, "b")), + LDContext.createMulti(LDContext.create(kind2, "b"), LDContext.create(kind1, "a")) // ordering of kinds doesn't matter + )); + values.add(asList( + LDContext.createMulti(LDContext.create(kind1, "a"), LDContext.create(kind2, "c")), + LDContext.createMulti(LDContext.create(kind1, "a"), LDContext.create(kind2, "c")) + )); + values.add(asList( + LDContext.createMulti(LDContext.create(kind1, "a"), LDContext.create(kind2, "b"), LDContext.create(kind3, "c")), + LDContext.createMulti(LDContext.create(kind1, "a"), LDContext.create(kind2, "b"), LDContext.create(kind3, "c")) + )); + values.add(asList(LDContext.create(""), LDContext.create(""))); // invalid context + values.add(asList(LDContext.createMulti(), LDContext.createMulti())); // invalid with a different error + return values; + } +} From b68142d3ecc0db15822b7a0922d340c9b0d350e8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 11 Jul 2022 23:45:41 -0700 Subject: [PATCH 67/91] LDContext JSON marshaling & unmarshaling --- .../java/com/launchdarkly/sdk/LDContext.java | 31 ++- .../sdk/LDContextTypeAdapter.java | 196 ++++++++++++++++++ .../sdk/json/JsonSerialization.java | 2 + .../com/launchdarkly/sdk/LDContextTest.java | 12 ++ .../json/LDContextJsonSerializationTest.java | 126 +++++++++++ 5 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java create mode 100644 src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java index 0373839..c147953 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContext.java +++ b/src/main/java/com/launchdarkly/sdk/LDContext.java @@ -1,5 +1,9 @@ package com.launchdarkly.sdk; +import com.google.gson.annotations.JsonAdapter; +import com.launchdarkly.sdk.json.JsonSerializable; +import com.launchdarkly.sdk.json.JsonSerialization; + import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.ArrayList; @@ -23,7 +27,14 @@ * An LDContext can be in an error state if it was built with invalid attributes. See * {@link #isValid()} and {@link #getError()}. */ -public final class LDContext { +@JsonAdapter(LDContextTypeAdapter.class) +public final class LDContext implements JsonSerializable { + static final String ATTR_KIND = "kind"; + static final String ATTR_KEY = "key"; + static final String ATTR_NAME = "name"; + static final String ATTR_ANONYMOUS = "anonymous"; + + final String error; final ContextKind kind; final List multiContexts; @@ -696,6 +707,24 @@ public String getFullyQualifiedKey() { return fullyQualifiedKey; } + /** + * Returns a string representation of the context. + *

    + * For a valid context, this is currently defined as being the same as the JSON representation, + * since that is the simplest way to represent all of the LDContext properties. However, + * application code should not rely on {@link #toString()} always being the same as the JSON + * representation. If you specifically want the latter, use {@link JsonSerialization#serialize(JsonSerializable)}. + *

    + * For an invalid context, {@link #toString()} returns a description of why it is invalid. + */ + @Override + public String toString() { + if (!isValid()) { + return ("(invalid LDContext: " + getError() + ")"); + } + return JsonSerialization.serialize(this); + } + @Override public boolean equals(Object other) { if (this == other) { diff --git a/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java new file mode 100644 index 0000000..f955e45 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java @@ -0,0 +1,196 @@ +package com.launchdarkly.sdk; + +import com.google.gson.JsonIOException; +import com.google.gson.JsonParseException; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.util.Map; + +import static com.launchdarkly.sdk.LDContext.ATTR_ANONYMOUS; +import static com.launchdarkly.sdk.LDContext.ATTR_KEY; +import static com.launchdarkly.sdk.LDContext.ATTR_KIND; +import static com.launchdarkly.sdk.LDContext.ATTR_NAME; + +final class LDContextTypeAdapter extends TypeAdapter { + private static final String JSON_PROP_META = "_meta"; + private static final String JSON_PROP_SECONDARY = "secondary"; + private static final String JSON_PROP_PRIVATE = "privateAttributes"; + private static final String JSON_PROP_OLD_PRIVATE = "privateAttributeNames"; + private static final String JSON_PROP_OLD_CUSTOM = "custom"; + + @Override + public void write(JsonWriter out, LDContext c) throws IOException { + if (!c.isValid()) { + throw new JsonIOException("tried to serialize invalid LDContext: " + c.getError()); + } + if (c.isMultiple()) { + out.beginObject(); + out.name(ATTR_KIND).value(ContextKind.MULTI.toString()); + for (LDContext c1: c.multiContexts) { + out.name(c1.getKind().toString()); + writeSingleKind(out, c1, false); + } + out.endObject(); + } else { + writeSingleKind(out, c, true); + } + } + + private void writeSingleKind(JsonWriter out, LDContext c, boolean includeKind) throws IOException { + out.beginObject(); + if (includeKind) { + out.name(ATTR_KIND).value(c.getKind().toString()); + } + out.name(ATTR_KEY).value(c.getKey()); + if (c.getName() != null) { + out.name(ATTR_NAME).value(c.getName()); + } + if (c.isAnonymous()) { + out.name(ATTR_ANONYMOUS).value(c.isAnonymous()); + } + if (c.attributes != null) { + for (Map.Entry kv: c.attributes.entrySet()) { + out.name(kv.getKey()); + LDValueTypeAdapter.INSTANCE.write(out, kv.getValue()); + } + } + if (c.getSecondary() != null || c.getPrivateAttributeCount() != 0) { + out.name(JSON_PROP_META).beginObject(); + if (c.getSecondary() != null) { + out.name(JSON_PROP_SECONDARY).value(c.getSecondary()); + } + if (c.getPrivateAttributeCount() != 0) { + out.name(JSON_PROP_PRIVATE).beginArray(); + for (AttributeRef a: c.privateAttributes) { + out.value(a.toString()); + } + out.endArray(); + } + out.endObject(); + } + out.endObject(); + } + + @Override + public LDContext read(JsonReader in) throws IOException { + LDValue obj = requireValueType(LDValueTypeAdapter.INSTANCE.read(in), LDValueType.OBJECT, false, null); + ContextKind kind = null; + for (String key: obj.keys()) { + if (key.equals(ATTR_KIND)) { + kind = ContextKind.of( + requireValueType(obj.get(key), LDValueType.STRING, false, ATTR_KIND).stringValue()); + break; + } + } + LDContext ret; + if (kind == null) { + ret = readOldUser(obj); + } else if (kind.equals(ContextKind.MULTI)) { + ContextMultiBuilder mb = LDContext.multiBuilder(); + for (String key: obj.keys()) { + if (!key.equals(ATTR_KIND)) { + mb.add(readSingleKind(obj.get(key), ContextKind.of(key))); + } + } + ret = mb.build(); + } else { + ret = readSingleKind(obj, null); + } + if (!ret.isValid()) { + throw new JsonParseException("invalid LDContext: " + ret.getError()); + } + return ret; + } + + private static LDValue requireValueType(LDValue v, LDValueType t, boolean nullable, String propName) throws JsonParseException { + if (v.getType() != t && !(nullable && v.isNull())) { + throw new JsonParseException("expected " + t + ", found " + v.getType() + + (propName == null ? "" : (" for " + propName))); + } + return v; + } + + private static LDContext readOldUser(LDValue obj) throws JsonParseException { + requireValueType(obj, LDValueType.OBJECT, false, null); + ContextBuilder cb = LDContext.builder(""); + for (String key: obj.keys()) { + LDValue v = obj.get(key); + switch (key) { + case ATTR_KEY: + cb.key(requireValueType(v, LDValueType.STRING, false, key).stringValue()); + break; + case ATTR_NAME: + cb.name(requireValueType(v, LDValueType.STRING, true, key).stringValue()); + break; + case ATTR_ANONYMOUS: + cb.anonymous(requireValueType(v, LDValueType.BOOLEAN, true, key).booleanValue()); + break; + case JSON_PROP_SECONDARY: + cb.secondary(requireValueType(v, LDValueType.STRING, true, key).stringValue()); + break; + case JSON_PROP_OLD_PRIVATE: + LDValue privateAttrs = requireValueType(v, LDValueType.ARRAY, true, JSON_PROP_OLD_PRIVATE); + for (LDValue privateAttr: privateAttrs.values()) { + cb.privateAttributes(AttributeRef.fromLiteral( + requireValueType(privateAttr, LDValueType.STRING, false, JSON_PROP_PRIVATE).stringValue())); + } + break; + case JSON_PROP_OLD_CUSTOM: + for (String customKey: v.keys()) { + cb.set(customKey, v.get(customKey)); + } + break; + case "firstName": + case "lastName": + case "email": + case "country": + case "ip": + case "avatar": + cb.set(key, requireValueType(v, LDValueType.STRING, true, key)); + break; + default: + break; + } + } + return cb.build(); + } + + private static LDContext readSingleKind(LDValue obj, ContextKind kind) throws JsonParseException { + requireValueType(obj, LDValueType.OBJECT, false, kind == null ? null : kind.toString()); + ContextBuilder cb = LDContext.builder("").kind(kind); + for (String key: obj.keys()) { + LDValue v = obj.get(key); + switch (key) { + case ATTR_KIND: + cb.kind(requireValueType(v, LDValueType.STRING, false, key).stringValue()); + break; + case ATTR_KEY: + cb.key(requireValueType(v, LDValueType.STRING, false, key).stringValue()); + break; + case ATTR_NAME: + cb.name(requireValueType(v, LDValueType.STRING, true, key).stringValue()); + break; + case ATTR_ANONYMOUS: + cb.anonymous(requireValueType(v, LDValueType.BOOLEAN, true, key).booleanValue()); + break; + case JSON_PROP_META: + LDValue meta = requireValueType(v, LDValueType.OBJECT, true, key); + cb.secondary(requireValueType(meta.get(JSON_PROP_SECONDARY), + LDValueType.STRING, true, JSON_PROP_SECONDARY).stringValue()); + LDValue privateAttrs = requireValueType(meta.get(JSON_PROP_PRIVATE), + LDValueType.ARRAY, true, JSON_PROP_PRIVATE); + for (LDValue privateAttr: privateAttrs.values()) { + cb.privateAttributes(AttributeRef.fromPath( + requireValueType(privateAttr, LDValueType.STRING, false, JSON_PROP_PRIVATE).stringValue())); + } + break; + default: + cb.set(key, v); + } + } + return cb.build(); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index 47cf71c..fd2bdb2 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -5,6 +5,7 @@ import com.launchdarkly.sdk.AttributeRef; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; @@ -147,6 +148,7 @@ static Iterable> getDeserializableClasses() { knownDeserializableClasses.add(AttributeRef.class); knownDeserializableClasses.add(EvaluationReason.class); knownDeserializableClasses.add(EvaluationDetail.class); + knownDeserializableClasses.add(LDContext.class); knownDeserializableClasses.add(LDUser.class); knownDeserializableClasses.add(LDValue.class); knownDeserializableClasses.add(UserAttribute.class); diff --git a/src/test/java/com/launchdarkly/sdk/LDContextTest.java b/src/test/java/com/launchdarkly/sdk/LDContextTest.java index 8f6948c..043d1ff 100644 --- a/src/test/java/com/launchdarkly/sdk/LDContextTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDContextTest.java @@ -1,5 +1,8 @@ package com.launchdarkly.sdk; +import com.launchdarkly.sdk.json.JsonSerialization; + +import org.hamcrest.Matchers; import org.junit.Test; import java.util.ArrayList; @@ -8,6 +11,7 @@ import static java.util.Arrays.asList; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.emptyIterable; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; @@ -329,6 +333,14 @@ public void multiKindContexts() { assertThat(invalid.getIndividualContextCount(), equalTo(0)); } + @Test + public void stringRepresentation() { + LDContext c = LDContext.create(kind1, "a"); + assertThat(c.toString(), equalTo(JsonSerialization.serialize(c))); + + assertThat(LDContext.create("").toString(), containsString(Errors.CONTEXT_NO_KEY)); + } + @Test public void equality() { List> values = makeValues(); diff --git a/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java new file mode 100644 index 0000000..317a1ba --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java @@ -0,0 +1,126 @@ +package com.launchdarkly.sdk.json; + +import com.google.gson.JsonIOException; +import com.google.gson.JsonParseException; +import com.launchdarkly.sdk.ContextKind; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; + +import org.junit.Test; + +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; + +@SuppressWarnings("javadoc") +public class LDContextJsonSerializationTest { + private static final ContextKind + kind1 = ContextKind.of("kind1"), + kind2 = ContextKind.of("kind2"); + + private static final LDValue[] ALL_TYPE_VALUES = new LDValue[] { + LDValue.of(true), LDValue.of(1), LDValue.of(1.5), LDValue.of("c"), + LDValue.arrayOf(), LDValue.buildObject().build() + }; + + @Test + public void minimalJsonEncoding() throws Exception { + LDContext context = LDContext.create("userkey"); + verifySerializeAndDeserialize(context, "{\"kind\":\"user\",\"key\":\"userkey\"}"); + + verifySerialize((LDContext)null, "null"); + } + + @Test + public void singleKindContexts() throws Exception { + verifySerializeAndDeserialize( + LDContext.create("a"), + "{\"kind\":\"user\",\"key\":\"a\"}"); + + verifySerializeAndDeserialize( + LDContext.create(kind1, "a"), + "{\"kind\":\"kind1\",\"key\":\"a\"}"); + + verifySerializeAndDeserialize( + LDContext.builder("a").name("b").build(), + "{\"kind\":\"user\",\"key\":\"a\",\"name\":\"b\"}"); + + verifySerializeAndDeserialize( + LDContext.builder("a").anonymous(true).build(), + "{\"kind\":\"user\",\"key\":\"a\",\"anonymous\":true}"); + + for (LDValue customValue: ALL_TYPE_VALUES) { + verifySerializeAndDeserialize( + LDContext.builder("a").set("b", customValue).build(), + "{\"kind\":\"user\",\"key\":\"a\",\"b\":" + customValue.toJsonString() + "}"); + } + + verifySerializeAndDeserialize( + LDContext.builder("a").secondary("b").build(), + "{\"kind\":\"user\",\"key\":\"a\",\"_meta\":{\"secondary\":\"b\"}}"); + + verifySerializeAndDeserialize( + LDContext.builder("a").privateAttributes("b").build(), + "{\"kind\":\"user\",\"key\":\"a\",\"_meta\":{\"privateAttributes\":[\"b\"]}}"); + + verifySerializeAndDeserialize( + LDContext.builder("a").privateAttributes("/b/c").build(), + "{\"kind\":\"user\",\"key\":\"a\",\"_meta\":{\"privateAttributes\":[\"/b/c\"]}}"); + } + + @Test + public void multiKindContext() throws Exception { + verifySerializeAndDeserialize( + LDContext.createMulti(LDContext.create(kind1, "a"), LDContext.create(kind2, "b")), + "{\"kind\":\"multi\",\"kind1\":{\"key\":\"a\"},\"kind2\":{\"key\":\"b\"}}"); + } + + @Test + public void convertOldUser() throws Exception { + verifyDeserialize(LDContext.create("a"), "{\"key\":\"a\"}"); + + verifyDeserialize(LDContext.builder("a").name("b").build(), + "{\"key\":\"a\",\"name\":\"b\"}"); + + verifyDeserialize(LDContext.builder("a").anonymous(true).build(), + "{\"key\":\"a\",\"anonymous\":true}"); + + verifyDeserialize(LDContext.builder("a").build(), + "{\"key\":\"a\",\"anonymous\":false}"); + + verifyDeserialize(LDContext.builder("a").build(), + "{\"key\":\"a\",\"anonymous\":null}"); + + for (String builtInName: new String[] { "firstName", "lastName", "email", "country", "ip", "avatar" }) { + verifyDeserialize(LDContext.builder("a").set(builtInName, "b").build(), + "{\"key\":\"a\",\"" + builtInName + "\":\"b\"}"); + } + + for (LDValue customValue: ALL_TYPE_VALUES) { + verifyDeserialize( + LDContext.builder("a").set("b", customValue).build(), + "{\"key\":\"a\",\"custom\":{\"b\":" + customValue.toJsonString() + "}}"); + } + + verifyDeserialize(LDContext.builder("a").secondary("b").build(), + "{\"key\":\"a\",\"secondary\":\"b\"}"); + + verifyDeserialize(LDContext.builder("a").privateAttributes("b").build(), + "{\"key\":\"a\",\"privateAttributeNames\":[\"b\"]}"); + } + + @Test(expected=JsonIOException.class) + public void serializeInvalidContext() throws Exception { + JsonSerialization.serialize(LDContext.create("")); + } + + @Test(expected=SerializationException.class) + public void deserializeContextWithValidationError() throws Exception { + JsonSerialization.deserialize("{\"kind\":\"a\",\"key\":\"\"}", LDContext.class); + } + + @Test(expected=SerializationException.class) + public void deserializeContextWithTypeError() throws Exception { + JsonSerialization.deserialize("{\"kind\":\"a\",\"key\":3}", LDContext.class); + } +} From ea4dfea6aaacfeac8d33ee8c9cde471979ef8335 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 Jul 2022 11:58:36 -0700 Subject: [PATCH 68/91] javadoc fix --- build.gradle.kts | 1 + src/main/java/com/launchdarkly/sdk/ContextBuilder.java | 2 +- src/test/java/com/launchdarkly/sdk/LDContextTest.java | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 5f6dbb6..512b1af 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,6 +45,7 @@ java { val privateImplementation by configurations.creating dependencies { // see Dependencies.kt in buildSrc + Libs.privateImplementation.forEach { implementation(it)} Libs.privateImplementation.forEach { privateImplementation(it)} Libs.javaTestImplementation.forEach { testImplementation(it) } } diff --git a/src/main/java/com/launchdarkly/sdk/ContextBuilder.java b/src/main/java/com/launchdarkly/sdk/ContextBuilder.java index ac195b4..935b4c6 100644 --- a/src/main/java/com/launchdarkly/sdk/ContextBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ContextBuilder.java @@ -122,7 +122,7 @@ public ContextBuilder key(String key) { *

    * This attribute is optional. It has the following special rules: *

      - *
    • Unlike most other attributes, it is always a string if it is specified.
    + *
  • Unlike most other attributes, it is always a string if it is specified.
  • *
  • The LaunchDarkly dashboard treats this attribute as the preferred display name * for contexts.
  • * diff --git a/src/test/java/com/launchdarkly/sdk/LDContextTest.java b/src/test/java/com/launchdarkly/sdk/LDContextTest.java index 043d1ff..1206ebd 100644 --- a/src/test/java/com/launchdarkly/sdk/LDContextTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDContextTest.java @@ -2,7 +2,6 @@ import com.launchdarkly.sdk.json.JsonSerialization; -import org.hamcrest.Matchers; import org.junit.Test; import java.util.ArrayList; From 046feb24df48dd643f12fc027c7f5ca46aac5e7d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 Jul 2022 11:58:36 -0700 Subject: [PATCH 69/91] javadoc fix --- src/main/java/com/launchdarkly/sdk/ContextBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/ContextBuilder.java b/src/main/java/com/launchdarkly/sdk/ContextBuilder.java index ac195b4..935b4c6 100644 --- a/src/main/java/com/launchdarkly/sdk/ContextBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ContextBuilder.java @@ -122,7 +122,7 @@ public ContextBuilder key(String key) { *

    * This attribute is optional. It has the following special rules: *

      - *
    • Unlike most other attributes, it is always a string if it is specified.
    + *
  • Unlike most other attributes, it is always a string if it is specified.
  • *
  • The LaunchDarkly dashboard treats this attribute as the preferred display name * for contexts.
  • * From c172113948fdad454e1969d11fac41ce37c36ba0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 Jul 2022 12:13:04 -0700 Subject: [PATCH 70/91] store multiContexts as an array rather than a List --- .../launchdarkly/sdk/ContextMultiBuilder.java | 10 ++----- .../java/com/launchdarkly/sdk/LDContext.java | 28 ++++++++++--------- .../sdk/ContextMultiBuilderTest.java | 2 +- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java b/src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java index 49c0a9f..5a4e681 100644 --- a/src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java @@ -32,7 +32,6 @@ */ public final class ContextMultiBuilder { private List contexts; - private boolean copyOnWrite; ContextMultiBuilder() {} @@ -62,8 +61,8 @@ public LDContext build() { return contexts.get(0); } - copyOnWrite = contexts != null; - return LDContext.createMulti(contexts); + LDContext[] contextsArray = contexts.toArray(new LDContext[contexts.size()]); + return LDContext.createMultiInternal(contextsArray); } /** @@ -77,10 +76,7 @@ public LDContext build() { */ public ContextMultiBuilder add(LDContext context) { if (context != null) { - if (copyOnWrite) { - contexts = new ArrayList<>(contexts); - copyOnWrite = false; - } else if (contexts == null) { + if (contexts == null) { contexts = new ArrayList<>(); } contexts.add(context); diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java index 0373839..36c4a4d 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContext.java +++ b/src/main/java/com/launchdarkly/sdk/LDContext.java @@ -26,7 +26,7 @@ public final class LDContext { final String error; final ContextKind kind; - final List multiContexts; + final LDContext[] multiContexts; final String key; final String fullyQualifiedKey; final String name; @@ -37,7 +37,7 @@ public final class LDContext { private LDContext( ContextKind kind, - List multiContexts, + LDContext[] multiContexts, String key, String fullyQualifiedKey, String name, @@ -99,11 +99,11 @@ static LDContext createSingle( // except for validating that there is more than one context. We take ownership of the list // that is passed in, so it is effectively immutable afterward; ContextMultiBuilder has // copy-on-write logic to manage that. - static LDContext createMulti(List multiContexts) { + static LDContext createMultiInternal(LDContext[] multiContexts) { List errors = null; boolean nestedMulti = false, duplicates = false; - for (int i = 0; i < multiContexts.size(); i++) { - LDContext c = multiContexts.get(i); + for (int i = 0; i < multiContexts.length; i++) { + LDContext c = multiContexts[i]; if (!c.isValid()) { if (errors == null) { errors = new ArrayList(); @@ -113,7 +113,7 @@ static LDContext createMulti(List multiContexts) { nestedMulti = true; } else { for (int j = 0; j < i; j++) { - if (multiContexts.get(j).getKind().equals(c.getKind())) { + if (multiContexts[j].getKind().equals(c.getKind())) { duplicates = true; break; } @@ -144,7 +144,7 @@ static LDContext createMulti(List multiContexts) { return failed(s.toString()); } - multiContexts.sort(ByKindComparator.INSTNACE); + Arrays.sort(multiContexts, ByKindComparator.INSTNACE); StringBuilder fullKey = new StringBuilder(); for (LDContext c: multiContexts) { if (fullKey.length() != 0) { @@ -217,7 +217,9 @@ public static LDContext createMulti(LDContext... contexts) { if (contexts.length == 1) { return contexts[0]; // just return a single-kind context } - return createMulti(Arrays.asList(contexts)); + // copy the array because the caller could've passed in an array that they will later mutate + LDContext[] copied = Arrays.copyOf(contexts, contexts.length); + return createMultiInternal(copied); } /** @@ -587,7 +589,7 @@ public int getIndividualContextCount() { if (error != null) { return 0; } - return multiContexts == null ? 1 : multiContexts.size(); + return multiContexts == null ? 1 : multiContexts.length; } /** @@ -607,7 +609,7 @@ public LDContext getIndividualContext(int index) { if (multiContexts == null) { return index == 0 ? this : null; } - return index < 0 || index >= multiContexts.size() ? null : multiContexts.get(index); + return index < 0 || index >= multiContexts.length ? null : multiContexts[index]; } /** @@ -715,11 +717,11 @@ public boolean equals(Object other) { return false; } if (isMultiple()) { - if (multiContexts.size() != o.multiContexts.size()) { + if (multiContexts.length != o.multiContexts.length) { return false; } - for (int i = 0; i < multiContexts.size(); i++) { - if (!multiContexts.get(i).equals(o.multiContexts.get(i))) { + for (int i = 0; i < multiContexts.length; i++) { + if (!multiContexts[i].equals(o.multiContexts[i])) { return false; } } diff --git a/src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java b/src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java index 77e02eb..5ff3775 100644 --- a/src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java @@ -58,7 +58,7 @@ public void builderValidationErrors() { } @Test - public void copyOnWrite() { + public void modifyingBuilderDoesNotAffectPreviouslyCreatedInstances() { LDContext c1 = LDContext.create(kind1, "key1"), c2 = LDContext.create(kind2, "key2"), c3 = LDContext.create(kind3, "key3"); From 8ee8f7512c9bff14e9ccf898f8c780239146a943 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 Jul 2022 12:24:55 -0700 Subject: [PATCH 71/91] revert accidental change --- build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 512b1af..5f6dbb6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,7 +45,6 @@ java { val privateImplementation by configurations.creating dependencies { // see Dependencies.kt in buildSrc - Libs.privateImplementation.forEach { implementation(it)} Libs.privateImplementation.forEach { privateImplementation(it)} Libs.javaTestImplementation.forEach { testImplementation(it) } } From 0496d90a23283a92c371bc4a49ec707e2c5658c0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 Jul 2022 17:37:13 -0700 Subject: [PATCH 72/91] add type adapter for ContextKind --- .../com/launchdarkly/sdk/ContextKind.java | 6 ++++- .../sdk/ContextKindTypeAdapter.java | 19 ++++++++++++++ .../sdk/json/JsonSerialization.java | 2 ++ .../ContextKindJsonSerializationTest.java | 26 +++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/launchdarkly/sdk/ContextKindTypeAdapter.java create mode 100644 src/test/java/com/launchdarkly/sdk/json/ContextKindJsonSerializationTest.java diff --git a/src/main/java/com/launchdarkly/sdk/ContextKind.java b/src/main/java/com/launchdarkly/sdk/ContextKind.java index b152f7a..3a6887f 100644 --- a/src/main/java/com/launchdarkly/sdk/ContextKind.java +++ b/src/main/java/com/launchdarkly/sdk/ContextKind.java @@ -1,5 +1,8 @@ package com.launchdarkly.sdk; +import com.google.gson.annotations.JsonAdapter; +import com.launchdarkly.sdk.json.JsonSerializable; + /** * A string identifier provided by the application to describe what kind of entity an * {@link LDContext} represents. @@ -23,7 +26,8 @@ * the top-level LDContext is always "multi" (the constant {@link #MULTI}); there is a * specific Kind for each of the contexts contained within it. */ -public final class ContextKind { +@JsonAdapter(ContextKindTypeAdapter.class) +public final class ContextKind implements JsonSerializable { /** * A constant for the default kind of "user". */ diff --git a/src/main/java/com/launchdarkly/sdk/ContextKindTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/ContextKindTypeAdapter.java new file mode 100644 index 0000000..af0c4c4 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/ContextKindTypeAdapter.java @@ -0,0 +1,19 @@ +package com.launchdarkly.sdk; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +final class ContextKindTypeAdapter extends TypeAdapter { + @Override + public ContextKind read(JsonReader reader) throws IOException { + return ContextKind.of(Helpers.readNonNullableString(reader)); + } + + @Override + public void write(JsonWriter writer, ContextKind k) throws IOException { + writer.value(k.toString()); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index fd2bdb2..9bdcdde 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -3,6 +3,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.launchdarkly.sdk.AttributeRef; +import com.launchdarkly.sdk.ContextKind; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDContext; @@ -146,6 +147,7 @@ static Iterable> getDeserializableClasses() { synchronized (knownDeserializableClasses) { if (knownDeserializableClasses.isEmpty()) { knownDeserializableClasses.add(AttributeRef.class); + knownDeserializableClasses.add(ContextKind.class); knownDeserializableClasses.add(EvaluationReason.class); knownDeserializableClasses.add(EvaluationDetail.class); knownDeserializableClasses.add(LDContext.class); diff --git a/src/test/java/com/launchdarkly/sdk/json/ContextKindJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/ContextKindJsonSerializationTest.java new file mode 100644 index 0000000..866ef62 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/ContextKindJsonSerializationTest.java @@ -0,0 +1,26 @@ +package com.launchdarkly.sdk.json; + +import com.launchdarkly.sdk.BaseTest; +import com.launchdarkly.sdk.ContextKind; + +import org.junit.Test; + +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; + +@SuppressWarnings("javadoc") +public class ContextKindJsonSerializationTest extends BaseTest { + @Test + public void serializationAndDeserialization() throws Exception { + verifySerializeAndDeserialize(ContextKind.DEFAULT, "\"user\""); + verifySerializeAndDeserialize(ContextKind.of("org"), "\"org\""); + } + + @Test + public void deserializeInvalid() throws Exception { + verifyDeserializeInvalidJson(ContextKind.class, "true"); + verifyDeserializeInvalidJson(ContextKind.class, "3"); + verifyDeserializeInvalidJson(ContextKind.class, "{}"); + verifyDeserializeInvalidJson(ContextKind.class, "[]"); + } +} From 66a4f6f16f666ff768eb065ac65a4490ee9f2c34 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 13 Jul 2022 13:45:33 -0700 Subject: [PATCH 73/91] add null check/defaulting on getIndividualContext by kind --- src/main/java/com/launchdarkly/sdk/LDContext.java | 5 ++++- src/test/java/com/launchdarkly/sdk/LDContextTest.java | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java index 36c4a4d..7163665 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContext.java +++ b/src/main/java/com/launchdarkly/sdk/LDContext.java @@ -622,10 +622,13 @@ public LDContext getIndividualContext(int index) { * {@link #createMulti(LDContext...)} or {@link #multiBuilder()}, and the return value on * success is the corresponding individual LDContext within. * - * @param kind the context kind to get + * @param kind the context kind to get; if null, defaults to {@link ContextKind#DEFAULT} * @return an {@link LDContext}, or null if that kind was not found */ public LDContext getIndividualContext(ContextKind kind) { + if (kind == null) { + kind = ContextKind.DEFAULT; + } if (multiContexts == null) { return this.kind.equals(kind) ? this : null; } diff --git a/src/test/java/com/launchdarkly/sdk/LDContextTest.java b/src/test/java/com/launchdarkly/sdk/LDContextTest.java index 8f6948c..993a269 100644 --- a/src/test/java/com/launchdarkly/sdk/LDContextTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDContextTest.java @@ -323,6 +323,7 @@ public void multiKindContexts() { LDContext uc1 = LDContext.create("key1"); LDContext multi2 = LDContext.createMulti(uc1, c2); assertThat(multi2.getIndividualContext(ContextKind.DEFAULT), sameInstance(uc1)); + assertThat(multi2.getIndividualContext((ContextKind)null), sameInstance(uc1)); assertThat(multi2.getIndividualContext(""), sameInstance(uc1)); LDContext invalid = LDContext.create(""); From 5131647e7b6d9c748bb4ad5e92c79cf94e20f40f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 14 Jul 2022 16:58:47 -0700 Subject: [PATCH 74/91] fix context kind/key validation logic --- .../com/launchdarkly/sdk/ContextBuilder.java | 10 ++++++- .../java/com/launchdarkly/sdk/Errors.java | 1 + .../java/com/launchdarkly/sdk/LDContext.java | 7 +++-- .../sdk/LDContextTypeAdapter.java | 16 +++++++++-- .../json/LDContextJsonSerializationTest.java | 28 +++++++++++++++++-- 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/ContextBuilder.java b/src/main/java/com/launchdarkly/sdk/ContextBuilder.java index 935b4c6..1c7c88f 100644 --- a/src/main/java/com/launchdarkly/sdk/ContextBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ContextBuilder.java @@ -40,6 +40,7 @@ public final class ContextBuilder { private List privateAttributes; private boolean copyOnWriteAttributes; private boolean copyOnWritePrivateAttributes; + private boolean allowEmptyKey; ContextBuilder() {} @@ -67,7 +68,7 @@ public LDContext build() { this.copyOnWriteAttributes = attributes != null; this.copyOnWritePrivateAttributes = privateAttributes != null; - return LDContext.createSingle(kind, key, name, attributes, secondary, anonymous, privateAttributes); + return LDContext.createSingle(kind, key, name, attributes, secondary, anonymous, privateAttributes, allowEmptyKey); } /** @@ -391,6 +392,13 @@ public ContextBuilder privateAttributes(AttributeRef... attributeRefs) { return this; } + // Deliberately not public - this is how we make it possible to deserialize an old-style user + // from JSON where the key is an empty string, because that was allowed in the old schema, + // whereas in all other cases a context key must not be an empty string. + void setAllowEmptyKey(boolean allowEmptyKey) { + this.allowEmptyKey = allowEmptyKey; + } + ContextBuilder copyFrom(LDContext context) { kind = context.getKind(); key = context.getKey(); diff --git a/src/main/java/com/launchdarkly/sdk/Errors.java b/src/main/java/com/launchdarkly/sdk/Errors.java index e517710..c5bef8d 100644 --- a/src/main/java/com/launchdarkly/sdk/Errors.java +++ b/src/main/java/com/launchdarkly/sdk/Errors.java @@ -9,6 +9,7 @@ private Errors() {} "attribute reference contained an escape character (~) that was not followed by 0 or 1"; static final String CONTEXT_NO_KEY = "context key must not be null or empty"; + static final String CONTEXT_KIND_CANNOT_BE_EMPTY = "context kind must not be empty in JSON"; static final String CONTEXT_KIND_CANNOT_BE_KIND = "\"kind\" is not a valid context kind"; static final String CONTEXT_KIND_INVALID_CHARS = "context kind contains disallowed characters"; static final String CONTEXT_KIND_MULTI_FOR_SINGLE = "context of kind \"multi\" must be created with NewMulti or NewMultiBuilder"; diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java index 0a58bb1..5f30ace 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContext.java +++ b/src/main/java/com/launchdarkly/sdk/LDContext.java @@ -90,7 +90,8 @@ static LDContext createSingle( Map attributes, String secondary, boolean anonymous, - List privateAttributes + List privateAttributes, + boolean allowEmptyKey // allowEmptyKey is true only when deserializing old-style user JSON ) { if (kind != null) { String error = kind.validateAsSingleKind(); @@ -98,7 +99,7 @@ static LDContext createSingle( return failed(error); } } - if (key == null || key.isEmpty()) { + if (key == null || (key.isEmpty() && !allowEmptyKey)) { return failed(Errors.CONTEXT_NO_KEY); } String fullyQualifiedKey = kind.isDefault() ? key : @@ -201,7 +202,7 @@ public static LDContext create(String key) { * @see #builder(ContextKind, String) */ public static LDContext create(ContextKind kind, String key) { - return createSingle(kind, key, null, null, null, false, null); + return createSingle(kind, key, null, null, null, false, null, false); } /** diff --git a/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java index f955e45..94445ae 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java @@ -115,7 +115,8 @@ private static LDValue requireValueType(LDValue v, LDValueType t, boolean nullab private static LDContext readOldUser(LDValue obj) throws JsonParseException { requireValueType(obj, LDValueType.OBJECT, false, null); - ContextBuilder cb = LDContext.builder(""); + ContextBuilder cb = LDContext.builder(null); + cb.setAllowEmptyKey(true); for (String key: obj.keys()) { LDValue v = obj.get(key); switch (key) { @@ -161,11 +162,19 @@ private static LDContext readOldUser(LDValue obj) throws JsonParseException { private static LDContext readSingleKind(LDValue obj, ContextKind kind) throws JsonParseException { requireValueType(obj, LDValueType.OBJECT, false, kind == null ? null : kind.toString()); ContextBuilder cb = LDContext.builder("").kind(kind); + boolean hasNonEmptyKind = kind != null; for (String key: obj.keys()) { LDValue v = obj.get(key); switch (key) { case ATTR_KIND: - cb.kind(requireValueType(v, LDValueType.STRING, false, key).stringValue()); + String s = requireValueType(v, LDValueType.STRING, false, key).stringValue(); + if (!s.isEmpty()) { + // We need this extra check because the builder, when used programmatically, treats an + // unset/emty kind the same as ContextKind.DEFAULT-- but that's not the behavior we + // want for JSON. + hasNonEmptyKind = true; + cb.kind(s); + } break; case ATTR_KEY: cb.key(requireValueType(v, LDValueType.STRING, false, key).stringValue()); @@ -191,6 +200,9 @@ private static LDContext readSingleKind(LDValue obj, ContextKind kind) throws Js cb.set(key, v); } } + if (!hasNonEmptyKind) { + return LDContext.failed(Errors.CONTEXT_KIND_CANNOT_BE_EMPTY); + } return cb.build(); } } diff --git a/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java index 317a1ba..0b2ed67 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.json; import com.google.gson.JsonIOException; -import com.google.gson.JsonParseException; import com.launchdarkly.sdk.ContextKind; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; @@ -11,6 +10,9 @@ import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; @SuppressWarnings("javadoc") public class LDContextJsonSerializationTest { @@ -107,6 +109,11 @@ public void convertOldUser() throws Exception { verifyDeserialize(LDContext.builder("a").privateAttributes("b").build(), "{\"key\":\"a\",\"privateAttributeNames\":[\"b\"]}"); + + // For old user JSON only, an empty key is allowed; an LDContext can't be constructed in this state. + LDContext contextWithEmptyKey = JsonSerialization.deserialize("{\"key\":\"\"}", LDContext.class); + assertTrue(contextWithEmptyKey.isValid()); + assertEquals("", contextWithEmptyKey.getKey()); } @Test(expected=JsonIOException.class) @@ -114,9 +121,24 @@ public void serializeInvalidContext() throws Exception { JsonSerialization.serialize(LDContext.create("")); } - @Test(expected=SerializationException.class) + @Test public void deserializeContextWithValidationError() throws Exception { - JsonSerialization.deserialize("{\"kind\":\"a\",\"key\":\"\"}", LDContext.class); + for (String json: new String[] { + "{\"kind\":\"\",\"key\":\"a\"}", + "{\"kind\":\"a\",\"key\":\"\"}", + "{\"kind\":\"kind\",\"key\":\"a\"}", + "{\"kind\":\"ørg\",\"key\":\"a\"}", + "{\"kind\":\"a\",\"key\":\"b\",\"name\":3}", + "{\"kind\":\"a\",\"key\":\"b\",\"anonymous\":\"x\"}", + "{\"kind\":\"a\",\"key\":\"b\",\"_meta\":\"x\"}", + "{\"key\":null}", + "{}", + }) { + try { + JsonSerialization.deserialize(json, LDContext.class); + fail("expected deserialization to fail, but it passed, for JSON: " + json); + } catch (SerializationException e) {} + } } @Test(expected=SerializationException.class) From c68bb3996199802f5f281af1dcaaed1f8b132383 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 14 Jul 2022 17:31:29 -0700 Subject: [PATCH 75/91] don't allow deserializing an empty string --- .../java/com/launchdarkly/sdk/json/JsonSerialization.java | 5 +++++ .../com/launchdarkly/sdk/json/SerializationException.java | 8 ++++++++ .../sdk/json/LDContextJsonSerializationTest.java | 1 + 3 files changed, 14 insertions(+) diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index 9bdcdde..7117179 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -86,6 +86,11 @@ public static T deserialize(String json, Class o // We use this internally in situations where generic type checking isn't desirable static T deserializeInternal(String json, Class objectClass) throws SerializationException { + if (json == null || json.isBlank()) { + // Annoyingly, Gson tolerates a totally empty input string and considers it equivalent to null, + // but that isn't a valid JSON document. + throw new SerializationException("input string was null/blank"); + } try { return gson.fromJson(json, objectClass); } catch (Exception e) { diff --git a/src/main/java/com/launchdarkly/sdk/json/SerializationException.java b/src/main/java/com/launchdarkly/sdk/json/SerializationException.java index 909416d..c922d67 100644 --- a/src/main/java/com/launchdarkly/sdk/json/SerializationException.java +++ b/src/main/java/com/launchdarkly/sdk/json/SerializationException.java @@ -16,4 +16,12 @@ public class SerializationException extends Exception { public SerializationException(Throwable cause) { super(cause); } + + /** + * Creates an instance. + * @param message a description of the error + */ + public SerializationException(String message) { + super(message); + } } diff --git a/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java index 0b2ed67..e05550f 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java @@ -133,6 +133,7 @@ public void deserializeContextWithValidationError() throws Exception { "{\"kind\":\"a\",\"key\":\"b\",\"_meta\":\"x\"}", "{\"key\":null}", "{}", + "" }) { try { JsonSerialization.deserialize(json, LDContext.class); From a57f4bbc67cb4768a61ad32a749d6f3e82aeeb63 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 15 Jul 2022 11:11:15 -0700 Subject: [PATCH 76/91] remove use of isBlank which requires Java 11 --- .../java/com/launchdarkly/sdk/json/JsonSerialization.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index 7117179..7d6df2c 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -86,10 +86,10 @@ public static T deserialize(String json, Class o // We use this internally in situations where generic type checking isn't desirable static T deserializeInternal(String json, Class objectClass) throws SerializationException { - if (json == null || json.isBlank()) { + if (json == null || json.isEmpty()) { // Annoyingly, Gson tolerates a totally empty input string and considers it equivalent to null, // but that isn't a valid JSON document. - throw new SerializationException("input string was null/blank"); + throw new SerializationException("input string was null/empty"); } try { return gson.fromJson(json, objectClass); From 5772ad081e749dbc1e4e14d17ec1d316632a0afb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 18 Jul 2022 16:47:10 -0700 Subject: [PATCH 77/91] ContextKind should be Comparable since it is string-like --- src/main/java/com/launchdarkly/sdk/ContextKind.java | 7 ++++++- src/main/java/com/launchdarkly/sdk/LDContext.java | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/ContextKind.java b/src/main/java/com/launchdarkly/sdk/ContextKind.java index b152f7a..318d1cd 100644 --- a/src/main/java/com/launchdarkly/sdk/ContextKind.java +++ b/src/main/java/com/launchdarkly/sdk/ContextKind.java @@ -23,7 +23,7 @@ * the top-level LDContext is always "multi" (the constant {@link #MULTI}); there is a * specific Kind for each of the contexts contained within it. */ -public final class ContextKind { +public final class ContextKind implements Comparable { /** * A constant for the default kind of "user". */ @@ -105,4 +105,9 @@ String validateAsSingleKind() { } return null; } + + @Override + public int compareTo(ContextKind o) { + return kindName.compareTo(o.kindName); + } } diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java index 7163665..98ebc35 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContext.java +++ b/src/main/java/com/launchdarkly/sdk/LDContext.java @@ -144,7 +144,7 @@ static LDContext createMultiInternal(LDContext[] multiContexts) { return failed(s.toString()); } - Arrays.sort(multiContexts, ByKindComparator.INSTNACE); + Arrays.sort(multiContexts, ByKindComparator.INSTANCE); StringBuilder fullKey = new StringBuilder(); for (LDContext c: multiContexts) { if (fullKey.length() != 0) { @@ -822,10 +822,10 @@ private static String urlEncodeKey(String key) { } private static class ByKindComparator implements Comparator { - static final ByKindComparator INSTNACE = new ByKindComparator(); + static final ByKindComparator INSTANCE = new ByKindComparator(); public int compare(LDContext c1, LDContext c2) { - return c1.getKind().toString().compareTo(c2.getKind().toString()); + return c1.getKind().compareTo(c2.getKind()); } } } From 627e2d53e5103deb7b9711b9f168bd870899e1a8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 26 Jul 2022 17:07:16 -0700 Subject: [PATCH 78/91] use percent-encoding for specific characters in fully-qualified key, not URLEncoder --- src/main/java/com/launchdarkly/sdk/LDContext.java | 14 ++++++-------- .../java/com/launchdarkly/sdk/LDContextTest.java | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java index ae74ea8..8c22d1b 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContext.java +++ b/src/main/java/com/launchdarkly/sdk/LDContext.java @@ -103,7 +103,7 @@ static LDContext createSingle( return failed(Errors.CONTEXT_NO_KEY); } String fullyQualifiedKey = kind.isDefault() ? key : - (kind.toString() + ":" + urlEncodeKey(key)); + (kind.toString() + ":" + escapeKeyForFullyQualifiedKey(key)); return new LDContext(kind, null, key, fullyQualifiedKey, name, attributes, secondary, anonymous, privateAttributes); } @@ -162,7 +162,7 @@ static LDContext createMultiInternal(LDContext[] multiContexts) { if (fullKey.length() != 0) { fullKey.append(':'); } - fullKey.append(c.getKind().toString()).append(':').append(urlEncodeKey(c.getKey())); + fullKey.append(c.getKind().toString()).append(':').append(escapeKeyForFullyQualifiedKey(c.getKey())); } return new LDContext(ContextKind.MULTI, multiContexts, "", fullKey.toString(), null, null, null, false, null); @@ -843,12 +843,10 @@ private LDValue getTopLevelAttribute(String attributeName) { } } - private static String urlEncodeKey(String key) { - try { - return URLEncoder.encode(key, "UTF-8"); - } catch (UnsupportedEncodingException e) { - return ""; // COVERAGE: not a reachable condition - } + private static String escapeKeyForFullyQualifiedKey(String key) { + // When building a FullyQualifiedKey, ':' and '%' are percent-escaped; we do not use a full + // URL-encoding function because implementations of this are inconsistent across platforms. + return key.replace("%", "%25").replace(":", "%3A"); } private static class ByKindComparator implements Comparator { diff --git a/src/test/java/com/launchdarkly/sdk/LDContextTest.java b/src/test/java/com/launchdarkly/sdk/LDContextTest.java index 0d45d27..db84923 100644 --- a/src/test/java/com/launchdarkly/sdk/LDContextTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDContextTest.java @@ -113,7 +113,7 @@ public void fullyQualifiedKey() { assertThat(LDContext.create("abc").getFullyQualifiedKey(), equalTo("abc")); assertThat(LDContext.create("abc:d").getFullyQualifiedKey(), equalTo("abc:d")); assertThat(LDContext.create(kind1, "key1").getFullyQualifiedKey(), equalTo("kind1:key1")); - assertThat(LDContext.create(kind1, "key:2").getFullyQualifiedKey(), equalTo("kind1:key%3A2")); + assertThat(LDContext.create(kind1, "my:key%x/y").getFullyQualifiedKey(), equalTo("kind1:my%3Akey%25x/y")); assertThat( LDContext.createMulti(LDContext.create(kind1, "key1"), LDContext.create(kind2, "key:2")).getFullyQualifiedKey(), equalTo("kind1:key1:kind2:key%3A2")); From 40065f30d0f03cd66e23c17729c996bf01a3c2ce Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 27 Jul 2022 16:58:26 -0700 Subject: [PATCH 79/91] flatten nested multi-kind contexts when building a multi-kind context --- .../launchdarkly/sdk/ContextMultiBuilder.java | 26 +++++++++++-- .../java/com/launchdarkly/sdk/Errors.java | 1 - .../java/com/launchdarkly/sdk/LDContext.java | 38 +++++++++++++------ .../sdk/ContextMultiBuilderTest.java | 18 +++++---- .../com/launchdarkly/sdk/LDContextTest.java | 12 +++--- 5 files changed, 65 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java b/src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java index 5a4e681..fe812d2 100644 --- a/src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ContextMultiBuilder.java @@ -68,8 +68,22 @@ public LDContext build() { /** * Adds an individual LDContext for a specific kind to the builer. *

    - * It is invalid to add more than one LDContext for the same kind, or to add a nested - * multi-kind LDContext. This error is detected when you call {@link #build()}. + * It is invalid to add more than one LDContext for the same kind, or to add an LDContext + * that is itself invalid. This error is detected when you call {@link #build()}. + *

    + * If the nested context is multi-kind, this is exactly equivalent to adding each of the + * individual kinds from it separately. For instance, in the following example, "multi1" and + * "multi2" end up being exactly the same: + *

    
    +   *     LDContext c1 = LDContext.create(ContextKind.of("kind1"), "key1");
    +   *     LDContext c2 = LDContext.create(ContextKind.of("kind2"), "key2");
    +   *     LDContext c3 = LDContext.create(ContextKind.of("kind3"), "key3");
    +   *
    +   *     LDContext multi1 = LDContext.multiBuilder().add(c1).add(c2).add(c3).build();
    +   *
    +   *     LDContext c1plus2 = LDContext.multiBuilder().add(c1).add(c2).build();
    +   *     LDContext multi2 = LDContext.multiBuilder().add(c1plus2).add(c3).build();
    +   * 
    * * @param context the context to add * @return the builder @@ -79,7 +93,13 @@ public ContextMultiBuilder add(LDContext context) { if (contexts == null) { contexts = new ArrayList<>(); } - contexts.add(context); + if (context.isMultiple()) { + for (LDContext c: context.multiContexts) { + contexts.add(c); + } + } else { + contexts.add(context); + } } return this; } diff --git a/src/main/java/com/launchdarkly/sdk/Errors.java b/src/main/java/com/launchdarkly/sdk/Errors.java index c5bef8d..46235e7 100644 --- a/src/main/java/com/launchdarkly/sdk/Errors.java +++ b/src/main/java/com/launchdarkly/sdk/Errors.java @@ -14,6 +14,5 @@ private Errors() {} static final String CONTEXT_KIND_INVALID_CHARS = "context kind contains disallowed characters"; static final String CONTEXT_KIND_MULTI_FOR_SINGLE = "context of kind \"multi\" must be created with NewMulti or NewMultiBuilder"; static final String CONTEXT_KIND_MULTI_WITH_NO_KINDS = "multi-kind context must contain at least one kind"; - static final String CONTEXT_KIND_MULTI_WITHIN_MULTI = "multi-kind context cannot contain other multi-kind contexts"; static final String CONTEXT_KIND_MULTI_DUPLICATES = "multi-kind context cannot have same kind more than once"; } diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java index ae74ea8..dfb3f96 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContext.java +++ b/src/main/java/com/launchdarkly/sdk/LDContext.java @@ -113,7 +113,7 @@ static LDContext createSingle( // copy-on-write logic to manage that. static LDContext createMultiInternal(LDContext[] multiContexts) { List errors = null; - boolean nestedMulti = false, duplicates = false; + boolean duplicates = false; for (int i = 0; i < multiContexts.length; i++) { LDContext c = multiContexts[i]; if (!c.isValid()) { @@ -121,8 +121,6 @@ static LDContext createMultiInternal(LDContext[] multiContexts) { errors = new ArrayList(); } errors.add(c.getError()); - } else if (c.isMultiple()) { - nestedMulti = true; } else { for (int j = 0; j < i; j++) { if (multiContexts[j].getKind().equals(c.getKind())) { @@ -132,12 +130,6 @@ static LDContext createMultiInternal(LDContext[] multiContexts) { } } } - if (nestedMulti) { - if (errors == null) { - errors = new ArrayList(); - } - errors.add(Errors.CONTEXT_KIND_MULTI_WITHIN_MULTI); - } if (duplicates) { if (errors == null) { errors = new ArrayList(); @@ -212,11 +204,24 @@ public static LDContext create(ContextKind kind, String key) { * or {@link #builder(String)}. *

    * For the returned LDContext to be valid, the contexts list must not be empty, and all of its - * elements must be single-kind LDContexts. Otherwise, the returned LDContext will be invalid as + * elements must be valid LDContexts. Otherwise, the returned LDContext will be invalid as * reported by {@link #getError()}. *

    - * If only one context parameter is given, the method returns a single-kind context (that is, - * just that same context) rather than a multi-kind context. + * If only one context parameter is given, the method returns that same context. + *

    + * If the nested context is multi-kind, this is exactly equivalent to adding each of the + * individual kinds from it separately. For instance, in the following example, "multi1" and + * "multi2" end up being exactly the same: + *

    
    +   *     LDContext c1 = LDContext.create(ContextKind.of("kind1"), "key1");
    +   *     LDContext c2 = LDContext.create(ContextKind.of("kind2"), "key2");
    +   *     LDContext c3 = LDContext.create(ContextKind.of("kind3"), "key3");
    +   *
    +   *     LDContext multi1 = LDContext.createMulti(c1, c2, c3);
    +   *
    +   *     LDContext c1plus2 = LDContext.createMulti(c1, c2);
    +   *     LDContext multi2 = LDContext.createMulti(c1plus2, c3);
    +   * 
    * * @param contexts a list of contexts * @return an LDContext @@ -229,6 +234,15 @@ public static LDContext createMulti(LDContext... contexts) { if (contexts.length == 1) { return contexts[0]; // just return a single-kind context } + for (LDContext c: contexts) { + if (c.isMultiple()) { + ContextMultiBuilder b = multiBuilder(); + for (LDContext c1: contexts) { + b.add(c1); + } + return b.build(); + } + } // copy the array because the caller could've passed in an array that they will later mutate LDContext[] copied = Arrays.copyOf(contexts, contexts.length); return createMultiInternal(copied); diff --git a/src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java b/src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java index 5ff3775..730d13c 100644 --- a/src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/ContextMultiBuilderTest.java @@ -29,19 +29,23 @@ public void builderWithOneKindReturnsSingleKindContext() { assertThat(c2, sameInstance(c1)); } + @Test + public void nestedMultiKindContextIsFlattened() { + LDContext c1 = LDContext.create(kind1, "key1"); + LDContext c2 = LDContext.create(kind2, "key2"); + LDContext c3 = LDContext.create(kind3, "key3"); + LDContext c1plus2 = LDContext.multiBuilder().add(c1).add(c2).build(); + + assertThat(LDContext.multiBuilder().add(c1plus2).add(c3).build(), + equalTo(LDContext.multiBuilder().add(c1).add(c2).add(c3).build())); + } + @Test public void builderValidationErrors() { shouldBeInvalid( LDContext.multiBuilder().build(), Errors.CONTEXT_KIND_MULTI_WITH_NO_KINDS); - shouldBeInvalid( - LDContext.multiBuilder() - .add(LDContext.create("key1")) - .add(LDContext.createMulti(LDContext.create(kind1, "key2"), LDContext.create(kind2, "key3"))) - .build(), - Errors.CONTEXT_KIND_MULTI_WITHIN_MULTI); - shouldBeInvalid( LDContext.multiBuilder() .add(LDContext.create(kind1, "key1")) diff --git a/src/test/java/com/launchdarkly/sdk/LDContextTest.java b/src/test/java/com/launchdarkly/sdk/LDContextTest.java index 0d45d27..8f4d35a 100644 --- a/src/test/java/com/launchdarkly/sdk/LDContextTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDContextTest.java @@ -71,13 +71,6 @@ public void invalidContexts() { shouldBeInvalid(LDContext.createMulti(), Errors.CONTEXT_KIND_MULTI_WITH_NO_KINDS); - shouldBeInvalid( - LDContext.createMulti( - LDContext.create("key1"), - LDContext.createMulti(LDContext.create(kind1, "key2"), LDContext.create(kind2, "key3")) - ), - Errors.CONTEXT_KIND_MULTI_WITHIN_MULTI); - shouldBeInvalid( LDContext.createMulti( LDContext.create(kind1, "key1"), @@ -331,6 +324,11 @@ public void multiKindContexts() { LDContext invalid = LDContext.create(""); assertThat(invalid.getIndividualContextCount(), equalTo(0)); + + LDContext c3 = LDContext.create(kind3, "key3"); + LDContext c1plus2 = LDContext.multiBuilder().add(c1).add(c2).build(); + + assertThat(LDContext.createMulti(c1plus2, c3), equalTo(LDContext.createMulti(c1, c2, c3))); } @Test From 87bb3beb8be30cd594e92802a607cd32c06b2e9a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 Jul 2022 12:31:48 -0700 Subject: [PATCH 80/91] fix user JSON validation to require that "custom" is an object or null --- .../java/com/launchdarkly/sdk/LDContextTypeAdapter.java | 2 +- .../sdk/json/LDContextJsonSerializationTest.java | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java index 94445ae..741cbfe 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java @@ -140,7 +140,7 @@ private static LDContext readOldUser(LDValue obj) throws JsonParseException { } break; case JSON_PROP_OLD_CUSTOM: - for (String customKey: v.keys()) { + for (String customKey: requireValueType(v, LDValueType.OBJECT, true, JSON_PROP_OLD_CUSTOM).keys()) { cb.set(customKey, v.get(customKey)); } break; diff --git a/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java index e05550f..a148a55 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java @@ -131,7 +131,14 @@ public void deserializeContextWithValidationError() throws Exception { "{\"kind\":\"a\",\"key\":\"b\",\"name\":3}", "{\"kind\":\"a\",\"key\":\"b\",\"anonymous\":\"x\"}", "{\"kind\":\"a\",\"key\":\"b\",\"_meta\":\"x\"}", + + // invalid old-style user JSON "{\"key\":null}", + "{\"key\":\"a\",\"name\":3}", + "{\"key\":\"a\",\"anonymous\":\"x\"}", + "{\"key\":\"a\",\"custom\":\"x\"}", + "{\"key\":\"a\",\"privateAttributeNames\":3}", + "{}", "" }) { From 7ac24b93bb0b27726f8d2fda03d04d227ce036b2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 28 Sep 2022 17:32:14 -0700 Subject: [PATCH 81/91] attribute ref components are always properties, not array indices --- .../com/launchdarkly/sdk/AttributeRef.java | 54 ++++--------------- .../java/com/launchdarkly/sdk/LDContext.java | 7 +-- .../launchdarkly/sdk/AttributeRefTest.java | 40 +++++++------- .../com/launchdarkly/sdk/LDContextTest.java | 25 --------- 4 files changed, 29 insertions(+), 97 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/AttributeRef.java b/src/main/java/com/launchdarkly/sdk/AttributeRef.java index adccfe0..3db3a7e 100644 --- a/src/main/java/com/launchdarkly/sdk/AttributeRef.java +++ b/src/main/java/com/launchdarkly/sdk/AttributeRef.java @@ -25,9 +25,8 @@ * attribute name. An attribute name can contain any characters, but must not be empty. *
  • If the first character is a slash, the string is interpreted as a slash-delimited * path where the first path component is an attribute name, and each subsequent path - * component is either the name of a property in a JSON object, or a decimal numeric string - * that is the index of an element in a JSON array. Any instances of the characters "/" or - * "~" in a path component are escaped as "~1" or "~0" respectively. This syntax + * component is the name of a property in a JSON object. Any instances of the characters "/" + * or "~" in a path component are escaped as "~1" or "~0" respectively. This syntax * deliberately resembles JSON Pointer, but no JSON Pointer behaviors other than those * mentioned here are supported.
  • * @@ -42,19 +41,9 @@ public final class AttributeRef implements JsonSerializable, Comparable= components.length ? null : components[index].name; - } - - /** - * Retrieves a single path component from the attribute reference in the form of an integer. - *

    - * This is equivalent to converting the string returned by {@link #getComponent(int)} to an - * integer, or null if it is not a numeric string. - * - * @param index the zero-based index of the desired path component - * @return the path component parsed as an integer, or null - */ - public Integer getComponentAsInteger(int index) { - if (components == null) { - return null; - } - return index < 0 || index >= components.length ? null : components[index].asInteger; + return index < 0 || index >= components.length ? null : components[index]; } /** diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java index 7e384b0..b6d678f 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContext.java +++ b/src/main/java/com/launchdarkly/sdk/LDContext.java @@ -573,12 +573,7 @@ public LDValue getValue(AttributeRef attributeRef) { } for (int i = 1; i < attributeRef.getDepth(); i++) { String component = attributeRef.getComponent(i); - Integer asInt = attributeRef.getComponentAsInteger(i); - if (asInt != null && value.getType() == LDValueType.ARRAY) { - value = value.get(asInt.intValue()); - } else { - value = value.get(component); - } + value = value.get(component); // returns LDValue.null() if either property isn't found or value isn't an object if (value.isNull()) { break; } diff --git a/src/test/java/com/launchdarkly/sdk/AttributeRefTest.java b/src/test/java/com/launchdarkly/sdk/AttributeRefTest.java index 8fb726d..b0da156 100644 --- a/src/test/java/com/launchdarkly/sdk/AttributeRefTest.java +++ b/src/test/java/com/launchdarkly/sdk/AttributeRefTest.java @@ -62,7 +62,6 @@ private void testRefWithNoLeadingSlash(String s) { assertThat(a.toString(), equalTo(s)); assertThat(a.getDepth(), equalTo(1)); assertThat(a.getComponent(0), equalTo(s)); - assertThat(a.getComponentAsInteger(0), nullValue()); } @Test @@ -79,7 +78,6 @@ private void testRefSimpleWithLeadingSlash(String s, String unescaped) { assertThat(a.toString(), equalTo(s)); assertThat(a.getDepth(), equalTo(1)); assertThat(a.getComponent(0), equalTo(unescaped)); - assertThat(a.getComponentAsInteger(0), nullValue()); } @Test @@ -97,36 +95,34 @@ private void testLiteral(String s, String escaped) { assertThat(a.toString(), equalTo(escaped)); assertThat(a.getDepth(), equalTo(1)); assertThat(a.getComponent(0), equalTo(s)); - assertThat(a.getComponentAsInteger(0), nullValue()); } @Test public void getComponent() { - testGetComponent("", 0, 0, null, null); - testGetComponent("key", 1, 0, "key", null); - testGetComponent("/key", 1, 0, "key", null); - testGetComponent("/a/b", 2, 0, "a", null); - testGetComponent("/a/b", 2, 1, "b", null); - testGetComponent("/a~1b/c", 2, 0, "a/b", null); - testGetComponent("/a~0b/c", 2, 0, "a~b", null); - testGetComponent("/a/10/20/30x", 4, 1, "10", 10); - testGetComponent("/a/10/20/30x", 4, 2, "20", 20); - testGetComponent("/a/10/20/30x", 4, 3, "30x", null); - testGetComponent("", 0, -1, null, null); - testGetComponent("key", 1, -1, null, null); - testGetComponent("key", 1, 1, null, null); - testGetComponent("/key", 1, -1, null, null); - testGetComponent("/key", 1, 1, null, null); - testGetComponent("/a/b", 2, -1, null, null); - testGetComponent("/a/b", 2, 2, null, null); + testGetComponent("", 0, 0, null); + testGetComponent("key", 1, 0, "key"); + testGetComponent("/key", 1, 0, "key"); + testGetComponent("/a/b", 2, 0, "a"); + testGetComponent("/a/b", 2, 1, "b"); + testGetComponent("/a~1b/c", 2, 0, "a/b"); + testGetComponent("/a~0b/c", 2, 0, "a~b"); + testGetComponent("/a/10/20/30x", 4, 1, "10"); + testGetComponent("/a/10/20/30x", 4, 2, "20"); + testGetComponent("/a/10/20/30x", 4, 3, "30x"); + testGetComponent("", 0, -1, null); + testGetComponent("key", 1, -1, null); + testGetComponent("key", 1, 1, null); + testGetComponent("/key", 1, -1, null); + testGetComponent("/key", 1, 1, null); + testGetComponent("/a/b", 2, -1, null); + testGetComponent("/a/b", 2, 2, null); } - private void testGetComponent(String input, int depth, int index, String expectedName, Integer expectedAsInt) { + private void testGetComponent(String input, int depth, int index, String expectedName) { AttributeRef a = AttributeRef.fromPath(input); assertThat(a.toString(), equalTo(input)); assertThat(a.getDepth(), equalTo(depth)); assertThat(a.getComponent(index), equalTo(expectedName)); - assertThat(a.getComponentAsInteger(index), equalTo(expectedAsInt)); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/LDContextTest.java b/src/test/java/com/launchdarkly/sdk/LDContextTest.java index 25b0ad6..d608edf 100644 --- a/src/test/java/com/launchdarkly/sdk/LDContextTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDContextTest.java @@ -255,31 +255,6 @@ public void getValueForRefCustomAttributeSingleKind() { expectAttributeNotFoundForRef( LDContext.builder("key").set("my-attr", "xyz").build(), "/my-attr/my-prop"); - - // element in array - expectAttributeFoundForRef(LDValue.of("good"), - LDContext.builder("key").set("my-attr", LDValue.parse("[\"bad\",\"good\",\"worse\"]")).build(), - "/my-attr/1"); - - // element in nested array in object - expectAttributeFoundForRef(LDValue.of("good"), - LDContext.builder("key").set("my-attr", LDValue.parse("{\"my-prop\":[\"bad\",\"good\",\"worse\"]}")).build(), - "/my-attr/my-prop/1"); - - // index too low in array - expectAttributeNotFoundForRef( - LDContext.builder("key").set("my-attr", LDValue.parse("[\"bad\",\"good\",\"worse\"]")).build(), - "/my-attr/-1"); - - // index too high in array - expectAttributeNotFoundForRef( - LDContext.builder("key").set("my-attr", LDValue.parse("[\"bad\",\"good\",\"worse\"]")).build(), - "/my-attr/3"); - - // index in value that is not an array - expectAttributeNotFoundForRef( - LDContext.builder("key").set("my-attr", "xyz").build(), - "/my-attr/0"); } @Test From 3e80fd33a48491c0b8eef870f804c5994122077d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 3 Oct 2022 11:59:35 -0700 Subject: [PATCH 82/91] disable Windows Java 11 build --- .circleci/config.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2cb624d..a54648d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,9 +24,10 @@ workflows: with-coverage: true requires: - build-linux - - build-test-windows: - name: Java 11 - Windows - OpenJDK - openjdk-version: 11.0.2.01 +# Windows Java 11 build is temporarily disabled - see story 171428 +# - test-windows: +# name: Java 11 - Windows - OpenJDK +# openjdk-version: 11.0.2.01 - build-test-windows: name: Java 17 - Windows - OpenJDK openjdk-version: 17.0.1 From f1065bb3c19e0f07ba674ccc03418cdc30d85749 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 13 Oct 2022 18:36:45 -0700 Subject: [PATCH 83/91] remove secondary meta-attribute --- .../com/launchdarkly/sdk/ContextBuilder.java | 35 ++-------------- .../java/com/launchdarkly/sdk/LDContext.java | 37 ++++------------ .../sdk/LDContextTypeAdapter.java | 21 +++------- .../java/com/launchdarkly/sdk/LDUser.java | 42 +------------------ .../launchdarkly/sdk/LDUserTypeAdapter.java | 3 -- .../com/launchdarkly/sdk/UserAttribute.java | 11 +---- .../launchdarkly/sdk/ContextBuilderTest.java | 8 +--- .../com/launchdarkly/sdk/LDContextTest.java | 11 +---- .../java/com/launchdarkly/sdk/LDUserTest.java | 8 ---- .../launchdarkly/sdk/UserAttributeTest.java | 6 --- .../json/LDContextJsonSerializationTest.java | 7 ---- .../sdk/json/LDUserJsonSerializationTest.java | 2 - 12 files changed, 23 insertions(+), 168 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/ContextBuilder.java b/src/main/java/com/launchdarkly/sdk/ContextBuilder.java index 1c7c88f..39687a5 100644 --- a/src/main/java/com/launchdarkly/sdk/ContextBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ContextBuilder.java @@ -35,7 +35,6 @@ public final class ContextBuilder { private String key; private String name; private Map attributes; - private String secondary; private boolean anonymous; private List privateAttributes; private boolean copyOnWriteAttributes; @@ -68,7 +67,7 @@ public LDContext build() { this.copyOnWriteAttributes = attributes != null; this.copyOnWritePrivateAttributes = privateAttributes != null; - return LDContext.createSingle(kind, key, name, attributes, secondary, anonymous, privateAttributes, allowEmptyKey); + return LDContext.createSingle(kind, key, name, attributes, anonymous, privateAttributes, allowEmptyKey); } /** @@ -162,40 +161,15 @@ public ContextBuilder anonymous(boolean anonymous) { this.anonymous = anonymous; return this; } - - /** - * Sets a secondary key for the context. - *

    - * This affects - * feature flag targeting as follows: if you have chosen to bucket contexts by a - * specific attribute, the secondary key (if set) is used to further distinguish between - * contexts that are otherwise identical according to that attribute. - *

    - * This is a metadata property, rather than an attribute that can be addressed in - * evaluations: that is, a rule clause that references the attribute name "secondary" - * will not use this value, but instead will use whatever value (if any) you have set - * for the name "secondary" with a method such as {@link #set(String, String)}). - *

    - * Setting this value to an empty string is not the same as leaving it unset. If you - * need to clear this, set it to null. - * - * @param secondary the secondary key, or null - * @return the builder - * @see LDContext#getSecondary() - */ - public ContextBuilder secondary(String secondary) { - this.secondary = secondary; - return this; - } - + /** * Sets the value of any attribute for the context. *

    * This includes only attributes that are addressable in evaluations-- not metadata - * such as {@link #secondary(String)}. If {@code attributeName} is "secondary" or + * such as {@link #privateAttributes(String...)}. If {@code attributeName} is * "privateAttributes", you will be setting an attribute with that name which you can * use in evaluations or to record data for your own purposes, but it will be unrelated - * to {@link #secondary(String)} and {@link #privateAttributes(String...)}. + * to {@link #privateAttributes(String...)}. *

    * This method uses the {@link LDValue} type to represent a value of any JSON type: * null, boolean, number, string, array, or object. For all attribute names that do @@ -404,7 +378,6 @@ ContextBuilder copyFrom(LDContext context) { key = context.getKey(); name = context.getName(); anonymous = context.isAnonymous(); - secondary = context.getSecondary(); attributes = context.attributes; privateAttributes = context.privateAttributes; copyOnWriteAttributes = true; diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java index b6d678f..3d16047 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContext.java +++ b/src/main/java/com/launchdarkly/sdk/LDContext.java @@ -42,7 +42,6 @@ public final class LDContext implements JsonSerializable { final String fullyQualifiedKey; final String name; final Map attributes; - final String secondary; final boolean anonymous; final List privateAttributes; @@ -53,7 +52,6 @@ private LDContext( String fullyQualifiedKey, String name, Map attributes, - String secondary, boolean anonymous, List privateAttributes ) { @@ -64,7 +62,6 @@ private LDContext( this.fullyQualifiedKey = fullyQualifiedKey; this.name = name; this.attributes = attributes; - this.secondary = secondary; this.anonymous = anonymous; this.privateAttributes = privateAttributes; } @@ -77,7 +74,6 @@ private LDContext(String error) { this.fullyQualifiedKey = ""; this.name = null; this.attributes = null; - this.secondary = null; this.anonymous = false; this.privateAttributes = null; } @@ -88,7 +84,6 @@ static LDContext createSingle( String key, String name, Map attributes, - String secondary, boolean anonymous, List privateAttributes, boolean allowEmptyKey // allowEmptyKey is true only when deserializing old-style user JSON @@ -104,7 +99,7 @@ static LDContext createSingle( } String fullyQualifiedKey = kind.isDefault() ? key : (kind.toString() + ":" + escapeKeyForFullyQualifiedKey(key)); - return new LDContext(kind, null, key, fullyQualifiedKey, name, attributes, secondary, anonymous, privateAttributes); + return new LDContext(kind, null, key, fullyQualifiedKey, name, attributes, anonymous, privateAttributes); } // Internal factory method for multi-kind contexts - implements all of the validation logic @@ -157,7 +152,7 @@ static LDContext createMultiInternal(LDContext[] multiContexts) { fullKey.append(c.getKind().toString()).append(':').append(escapeKeyForFullyQualifiedKey(c.getKey())); } return new LDContext(ContextKind.MULTI, multiContexts, "", fullKey.toString(), - null, null, null, false, null); + null, null, false, null); } // Internal factory method for a context in an invalid state. @@ -194,7 +189,7 @@ public static LDContext create(String key) { * @see #builder(ContextKind, String) */ public static LDContext create(ContextKind kind, String key) { - return createSingle(kind, key, null, null, null, false, null, false); + return createSingle(kind, key, null, null, false, null, false); } /** @@ -468,28 +463,11 @@ public boolean isAnonymous() { return anonymous; } - /** - * Returns the context's optional secondary key attribute. - *

    - * For a single-kind context, this value is set by {@link ContextBuilder#secondary(String)}. - * It is null if no value was set. - *

    - * For a multi-kind context, there is no single value and {@link #getSecondary()} returns null. - * Use {@link #getIndividualContext(int)} or {@link #getIndividualContext(String)} to - * inspect the LDContext for a particular kind, then call {@link #getSecondary()} on it. - * - * @return the secondary key or null - * @see ContextBuilder#secondary(String) - */ - public String getSecondary() { - return secondary; - } - /** * Looks up the value of any attribute of the context by name. *

    * This includes only attributes that are addressable in evaluations-- not metadata such - * as {@link #getSecondary()}. + * as {@link #getPrivateAttribute(int)}. *

    * For a single-kind context, the attribute name can be any custom attribute that was set * by methods like {@link ContextBuilder#set(String, boolean)}. It can also be one of the @@ -526,7 +504,7 @@ public LDValue getValue(String attributeName) { * attribute, based on an {@link AttributeRef}. *

    * This includes only attributes that are addressable in evaluations-- not metadata such - * as {@link #getSecondary()}. + * as {@link #getPrivateAttribute(int)}. *

    * This implements the same behavior that the SDK uses to resolve attribute references * during a flag evaluation. In a single-kind context, the {@link AttributeRef} can @@ -769,8 +747,7 @@ public boolean equals(Object other) { } return true; } - if (!key.equals(o.key) || !Objects.equals(name, o.name) || anonymous != o.anonymous || - !Objects.equals(secondary, o.secondary)) { + if (!key.equals(o.key) || !Objects.equals(name, o.name) || anonymous != o.anonymous) { return false; } if ((attributes == null ? 0 : attributes.size()) != @@ -810,7 +787,7 @@ public int hashCode() { // of attribute names. That's necessary just for the sake of aligning with the behavior of equals(), // which is insensitive to ordering. However, using an LDContext as a map key is not an anticipated // or recommended use case. - int h = Objects.hash(error, kind, key, name, anonymous, secondary); + int h = Objects.hash(error, kind, key, name, anonymous); if (multiContexts != null) { for (LDContext c: multiContexts) { h = h * 17 + c.hashCode(); diff --git a/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java index 741cbfe..f0ca4d1 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java @@ -16,7 +16,6 @@ final class LDContextTypeAdapter extends TypeAdapter { private static final String JSON_PROP_META = "_meta"; - private static final String JSON_PROP_SECONDARY = "secondary"; private static final String JSON_PROP_PRIVATE = "privateAttributes"; private static final String JSON_PROP_OLD_PRIVATE = "privateAttributeNames"; private static final String JSON_PROP_OLD_CUSTOM = "custom"; @@ -57,18 +56,13 @@ private void writeSingleKind(JsonWriter out, LDContext c, boolean includeKind) t LDValueTypeAdapter.INSTANCE.write(out, kv.getValue()); } } - if (c.getSecondary() != null || c.getPrivateAttributeCount() != 0) { + if (c.getPrivateAttributeCount() != 0) { out.name(JSON_PROP_META).beginObject(); - if (c.getSecondary() != null) { - out.name(JSON_PROP_SECONDARY).value(c.getSecondary()); - } - if (c.getPrivateAttributeCount() != 0) { - out.name(JSON_PROP_PRIVATE).beginArray(); - for (AttributeRef a: c.privateAttributes) { - out.value(a.toString()); - } - out.endArray(); + out.name(JSON_PROP_PRIVATE).beginArray(); + for (AttributeRef a: c.privateAttributes) { + out.value(a.toString()); } + out.endArray(); out.endObject(); } out.endObject(); @@ -129,9 +123,6 @@ private static LDContext readOldUser(LDValue obj) throws JsonParseException { case ATTR_ANONYMOUS: cb.anonymous(requireValueType(v, LDValueType.BOOLEAN, true, key).booleanValue()); break; - case JSON_PROP_SECONDARY: - cb.secondary(requireValueType(v, LDValueType.STRING, true, key).stringValue()); - break; case JSON_PROP_OLD_PRIVATE: LDValue privateAttrs = requireValueType(v, LDValueType.ARRAY, true, JSON_PROP_OLD_PRIVATE); for (LDValue privateAttr: privateAttrs.values()) { @@ -187,8 +178,6 @@ private static LDContext readSingleKind(LDValue obj, ContextKind kind) throws Js break; case JSON_PROP_META: LDValue meta = requireValueType(v, LDValueType.OBJECT, true, key); - cb.secondary(requireValueType(meta.get(JSON_PROP_SECONDARY), - LDValueType.STRING, true, JSON_PROP_SECONDARY).stringValue()); LDValue privateAttrs = requireValueType(meta.get(JSON_PROP_PRIVATE), LDValueType.ARRAY, true, JSON_PROP_PRIVATE); for (LDValue privateAttr: privateAttrs.values()) { diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index 234f12b..77a23f1 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -41,7 +41,6 @@ public class LDUser implements JsonSerializable { // 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. final LDValue key; - final LDValue secondary; final LDValue ip; final LDValue email; final LDValue name; @@ -57,7 +56,6 @@ protected LDUser(Builder builder) { 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); @@ -75,7 +73,7 @@ protected LDUser(Builder builder) { */ 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 = + this.ip = this.email = this.name = this.avatar = this.firstName = this.lastName = this.anonymous = this.country = LDValue.ofNull(); this.custom = null; this.privateAttributeNames = null; @@ -90,15 +88,6 @@ public String getKey() { return key.stringValue(); } - /** - * Returns the value of the secondary key property for the user, if set. - * - * @return a string or null - */ - public String getSecondary() { - return secondary.stringValue(); - } - /** * Returns the value of the IP property for the user, if set. * @@ -227,7 +216,6 @@ public boolean equals(Object o) { if (o instanceof LDUser) { 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) && @@ -244,7 +232,7 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(key, secondary, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); + return Objects.hash(key, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); } @Override @@ -264,7 +252,6 @@ public String toString() { */ public static class Builder { private String key; - private String secondary; private String ip; private String firstName; private String lastName; @@ -292,7 +279,6 @@ public Builder(String key) { */ public Builder(LDUser user) { this.key = user.key.stringValue(); - this.secondary = user.secondary.stringValue(); this.ip = user.ip.stringValue(); this.firstName = user.firstName.stringValue(); this.lastName = user.lastName.stringValue(); @@ -338,30 +324,6 @@ public Builder privateIp(String s) { 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(UserAttribute.SECONDARY_KEY); - return secondary(s); - } - /** * Set the country for a user. Before version 5.0.0, this field was validated and normalized by the SDK * as an ISO-3166-1 country code before assignment. This behavior has been removed so that the SDK can diff --git a/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java index 9528781..1099670 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java @@ -22,9 +22,6 @@ public LDUser read(JsonReader reader) throws IOException { case "key": builder.key(readNullableString(reader)); break; - case "secondary": - builder.secondary(readNullableString(reader)); - break; case "ip": builder.ip(readNullableString(reader)); break; diff --git a/src/main/java/com/launchdarkly/sdk/UserAttribute.java b/src/main/java/com/launchdarkly/sdk/UserAttribute.java index f8fb8ec..45276c6 100644 --- a/src/main/java/com/launchdarkly/sdk/UserAttribute.java +++ b/src/main/java/com/launchdarkly/sdk/UserAttribute.java @@ -32,15 +32,6 @@ public LDValue apply(LDUser u) { } }); - /** - * Represents the secondary key attribute. - */ - public static final UserAttribute SECONDARY_KEY = new UserAttribute("secondary", new Function() { - public LDValue apply(LDUser u) { - return u.secondary; - } - }); - /** * Represents the IP address attribute. */ @@ -117,7 +108,7 @@ public LDValue apply(LDUser u) { static final Map BUILTINS; static { BUILTINS = new HashMap<>(); - for (UserAttribute a: new UserAttribute[] { KEY, SECONDARY_KEY, IP, EMAIL, NAME, AVATAR, FIRST_NAME, LAST_NAME, COUNTRY, ANONYMOUS }) { + for (UserAttribute a: new UserAttribute[] { KEY, IP, EMAIL, NAME, AVATAR, FIRST_NAME, LAST_NAME, COUNTRY, ANONYMOUS }) { BUILTINS.put(a.getName(), a); } } diff --git a/src/test/java/com/launchdarkly/sdk/ContextBuilderTest.java b/src/test/java/com/launchdarkly/sdk/ContextBuilderTest.java index 6400b03..d30bff9 100644 --- a/src/test/java/com/launchdarkly/sdk/ContextBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/ContextBuilderTest.java @@ -42,10 +42,6 @@ public void setValueCanSetBuiltInPropertiesToValidValueType() { @Test public void setValueCannotSetMetaProperties() { - LDContext c1 = LDContext.builder("key").set("secondary", "x").build(); - assertThat(c1.getSecondary(), nullValue()); - assertThat(c1.getValue("secondary"), equalTo(LDValue.of("x"))); - LDContext c2 = LDContext.builder("key").set("privateAttributes", "x").build(); assertThat(c2.getPrivateAttributeCount(), equalTo(0)); assertThat(c2.getValue("privateAttributes"), equalTo(LDValue.of("x"))); @@ -54,8 +50,8 @@ public void setValueCannotSetMetaProperties() { @Test public void setValueIgnoresInvalidNamesAndInvalidValueTypes() { LDContext c = LDContext.builder("key").set("_meta", - LDValue.buildObject().put("secondary", "x").build()).build(); - assertThat(c.getSecondary(), nullValue()); + LDValue.buildObject().put("privateAttributes", LDValue.arrayOf(LDValue.of("a"))).build()).build(); + assertThat(c.getPrivateAttributeCount(), equalTo(0)); assertThat(c.getValue("_meta"), equalTo(LDValue.ofNull())); assertThat(LDContext.builder(kind1, "key").set("kind", LDValue.of(1)).build().getKind(), equalTo(kind1)); diff --git a/src/test/java/com/launchdarkly/sdk/LDContextTest.java b/src/test/java/com/launchdarkly/sdk/LDContextTest.java index d608edf..e6899c6 100644 --- a/src/test/java/com/launchdarkly/sdk/LDContextTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDContextTest.java @@ -34,7 +34,6 @@ public void singleKindConstructors() { assertThat(c1.getKey(), equalTo("x")); assertThat(c1.getName(), nullValue()); assertThat(c1.isAnonymous(), is(false)); - assertThat(c1.getSecondary(), nullValue()); assertThat(c1.getCustomAttributeNames(), emptyIterable()); LDContext c2 = LDContext.create(kind1, "x"); @@ -42,7 +41,6 @@ public void singleKindConstructors() { assertThat(c2.getKey(), equalTo("x")); assertThat(c2.getName(), nullValue()); assertThat(c2.isAnonymous(), is(false)); - assertThat(c2.getSecondary(), nullValue()); assertThat(c2.getCustomAttributeNames(), emptyIterable()); } @@ -55,7 +53,6 @@ public void singleKindBuilderProperties() { assertThat(LDContext.builder(".").anonymous(true).build().isAnonymous(), is(true)); assertThat(LDContext.builder(".").anonymous(true).anonymous(false).build().isAnonymous(), is(false)); assertThat(LDContext.builder(".").set("a", "x").build().getValue("a"), equalTo(LDValue.of("x"))); - assertThat(LDContext.builder(".").secondary("x").build().getSecondary(), equalTo("x")); } @Test @@ -122,7 +119,7 @@ public void customAttributeNames() { containsInAnyOrder("email", "happy")); // meta-attributes and non-optional attributes are not included - assertThat(LDContext.builder("a").secondary("b").anonymous(true).build().getCustomAttributeNames(), + assertThat(LDContext.builder("a").anonymous(true).privateAttributes("email").build().getCustomAttributeNames(), emptyIterable()); // none for multi-kind context @@ -223,7 +220,6 @@ private static void expectAttributeNotFoundForRef(LDContext c, String ref) { @Test public void getValueForRefCannotGetMetaProperties() { expectAttributeNotFoundForRef(LDContext.builder("key").privateAttributes("attr").build(), "privateAttributes"); - expectAttributeNotFoundForRef(LDContext.builder("key").secondary("my-value").build(), "secondary"); } @Test @@ -333,10 +329,7 @@ static List> makeValues() { values.add(asList(LDContext.create(kind1, "b"), LDContext.create(kind1, "b"))); values.add(asList(LDContext.builder("a").name("b").build(), LDContext.builder("a").name("b").build())); - - values.add(asList(LDContext.builder("a").secondary("b").build(), LDContext.builder("a").secondary("b").build())); - values.add(asList(LDContext.builder("a").secondary("").build(), LDContext.builder("a").secondary("").build())); - + values.add(asList(LDContext.builder("a").anonymous(true).build(), LDContext.builder("a").anonymous(true).build())); values.add(asList(LDContext.builder("a").set("b", true).build(), LDContext.builder("a").set("b", true).build())); diff --git a/src/test/java/com/launchdarkly/sdk/LDUserTest.java b/src/test/java/com/launchdarkly/sdk/LDUserTest.java index 8b741fa..7eb0230 100644 --- a/src/test/java/com/launchdarkly/sdk/LDUserTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDUserTest.java @@ -25,13 +25,6 @@ @SuppressWarnings("javadoc") public class LDUserTest extends BaseTest { private static enum OptionalStringAttributes { - secondary( - new Function() { public String apply(LDUser u) { return u.getSecondary(); } }, - new BiFunction() - { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.secondary(s); } }, - new BiFunction() - { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateSecondary(s); } }), - ip( new Function() { public String apply(LDUser u) { return u.getIp(); } }, new BiFunction() @@ -238,7 +231,6 @@ public void builderSetsPrivateCustomAttributes() { @Test public void canCopyUserWithBuilder() { LDUser user = new LDUser.Builder("key") - .secondary("secondary") .ip("127.0.0.1") .firstName("Bob") .lastName("Loblaw") diff --git a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java index 9de50b0..24932f4 100644 --- a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java +++ b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java @@ -19,12 +19,6 @@ public void keyAttribute() { assertTrue(UserAttribute.KEY.isBuiltIn()); } - @Test - public void secondaryKeyAttribute() { - assertEquals("secondary", UserAttribute.SECONDARY_KEY.getName()); - assertTrue(UserAttribute.SECONDARY_KEY.isBuiltIn()); - } - @Test public void ipAttribute() { assertEquals("ip", UserAttribute.IP.getName()); diff --git a/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java index a148a55..ce9cfc0 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDContextJsonSerializationTest.java @@ -57,10 +57,6 @@ public void singleKindContexts() throws Exception { "{\"kind\":\"user\",\"key\":\"a\",\"b\":" + customValue.toJsonString() + "}"); } - verifySerializeAndDeserialize( - LDContext.builder("a").secondary("b").build(), - "{\"kind\":\"user\",\"key\":\"a\",\"_meta\":{\"secondary\":\"b\"}}"); - verifySerializeAndDeserialize( LDContext.builder("a").privateAttributes("b").build(), "{\"kind\":\"user\",\"key\":\"a\",\"_meta\":{\"privateAttributes\":[\"b\"]}}"); @@ -104,9 +100,6 @@ public void convertOldUser() throws Exception { "{\"key\":\"a\",\"custom\":{\"b\":" + customValue.toJsonString() + "}}"); } - verifyDeserialize(LDContext.builder("a").secondary("b").build(), - "{\"key\":\"a\",\"secondary\":\"b\"}"); - verifyDeserialize(LDContext.builder("a").privateAttributes("b").build(), "{\"key\":\"a\",\"privateAttributeNames\":[\"b\"]}"); diff --git a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java index 95370da..09c0c27 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java @@ -29,7 +29,6 @@ public void minimalJsonEncoding() throws Exception { @Test public void defaultJsonEncodingWithoutPrivateAttributes() throws Exception { LDUser user = new LDUser.Builder("userkey") - .secondary("s") .ip("i") .email("e") .name("n") @@ -43,7 +42,6 @@ public void defaultJsonEncodingWithoutPrivateAttributes() throws Exception { .build(); LDValue expectedJson = LDValue.buildObject() .put("key", "userkey") - .put("secondary", "s") .put("ip", "i") .put("email", "e") .put("name", "n") From 4277c6e504e9314d0691f31ac505f53b8f69edfd Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 13 Oct 2022 20:03:55 -0700 Subject: [PATCH 84/91] remove LDUser as a concrete type --- .../java/com/launchdarkly/sdk/LDUser.java | 379 ++++-------------- .../launchdarkly/sdk/LDUserTypeAdapter.java | 133 ------ .../java/com/launchdarkly/sdk/LDValue.java | 2 +- .../com/launchdarkly/sdk/UserAttribute.java | 197 --------- .../sdk/json/JsonSerialization.java | 4 - .../com/launchdarkly/sdk/json/LDGson.java | 4 +- .../com/launchdarkly/sdk/json/LDJackson.java | 4 +- .../java/com/launchdarkly/sdk/LDUserTest.java | 210 ++-------- .../com/launchdarkly/sdk/TestHelpers.java | 4 - .../launchdarkly/sdk/UserAttributeTest.java | 85 ---- .../com/launchdarkly/sdk/json/LDGsonTest.java | 26 +- .../sdk/json/LDUserJsonSerializationTest.java | 93 ----- .../UserAttributeJsonSerializationTest.java | 20 - 13 files changed, 129 insertions(+), 1032 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java delete mode 100644 src/main/java/com/launchdarkly/sdk/UserAttribute.java delete mode 100644 src/test/java/com/launchdarkly/sdk/UserAttributeTest.java delete mode 100644 src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java delete mode 100644 src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index 77a23f1..a47f738 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -1,245 +1,45 @@ package com.launchdarkly.sdk; -import com.google.gson.annotations.JsonAdapter; -import com.launchdarkly.sdk.json.JsonSerializable; -import com.launchdarkly.sdk.json.JsonSerialization; - -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Objects; -import java.util.Set; - -import static java.util.Collections.unmodifiableMap; -import static java.util.Collections.unmodifiableSet; - /** - * A collection of attributes that can affect flag evaluation, usually corresponding to a user of your application. + * Contains legacy methods for constructing simple evaluation contexts, using the older LaunchDarkly + * SDK model for user properties. *

    - * The only mandatory property is the {@code key}, which must uniquely identify each user; this could be a username - * or email address for authenticated users, or a session ID for anonymous users. All other built-in properties are - * optional. You may also define custom properties with arbitrary names and values. + * The SDK now uses the type {@link LDContext} to represent an evaluation context that might + * represent a user, or some other kind of entity, or multiple kinds. In older SDK versions, + * this was limited to one kind and was represented by the type {@code LDUser}. This differed from + * LDContext in several ways: + *

      + *
    • There was always a single implicit context kind of "user".
    • + *
    • Unlike LDContext where only a few attributes such as {@link ContextBuilder#key(String)} + * and {@link ContextBuilder#name(String)} have special behavior, the user model defined many + * other built-in attributes such as {@code email} which, like {@code name}, were constrained to + * only allow string values. These had specific setter methods in {@link LDUser.Builder}.
    • + *
    *

    - * For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference - * guides on Setting user attributes - * and Targeting users. + * The LDUser class now exists only as a container for {@link LDUser.Builder}, which has been + * modified to be a wrapper for {@link ContextBuilder}. This allows code that used the older + * older model to still work with minor adjustments. *

    - * LaunchDarkly defines a standard JSON encoding for user objects, used by the JavaScript SDK and also in analytics - * events. {@link LDUser} can be converted to and from JSON in any of these ways: - *

      - *
    1. With {@link 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}. - *
    + * For any code that still uses this builder, the significant differences from older SDK + * versions are: + *
      + *
    • The concrete type being constructed is {@link LDContext}, so you will need to update + * any part of your code that referred to LDUser as a concrete type.
    • + *
    • The SDK no longer supports setting the key to an empty string. If you do this, + * the returned LDContext will be invalid (as indicated by {@link LDContext#isValid()}) and + * the SDK will refuse to use it for evaluations or events.
    • + *
    • Previously, the {@link LDUser.Builder#anonymous(boolean)} property had three states: + * true, false, or undefined/null. Undefined/null and false were functionally the same in terms + * of the LaunchDarkly dashboard/indexing behavior, but they were represented differently in + * JSON and could behave differently if referenced in a flag rule (an undefined/null value + * would not match "anonymous is false"). Now, the property is a simple boolean defaulting to + * false, and the undefined state is the same as false.
    • + *
    • The {@code secondary} attribute no longer exists.
    • + *
    */ -@JsonAdapter(LDUserTypeAdapter.class) -public class LDUser implements JsonSerializable { - // 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. - final LDValue key; - final LDValue ip; - final LDValue email; - final LDValue name; - final LDValue avatar; - final LDValue firstName; - final LDValue lastName; - final LDValue anonymous; - final LDValue country; - final Map custom; - Set privateAttributeNames; - - protected LDUser(Builder builder) { - this.key = LDValue.of(builder.key); - this.ip = LDValue.of(builder.ip); - this.country = LDValue.of(builder.country); - 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 : unmodifiableMap(builder.custom); - this.privateAttributeNames = builder.privateAttributes == null ? null : unmodifiableSet(builder.privateAttributes); - } - - /** - * 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.ip = this.email = this.name = this.avatar = this.firstName = this.lastName = this.anonymous = this.country = - LDValue.ofNull(); - this.custom = null; - this.privateAttributeNames = null; - } - - /** - * Returns the user's unique key. - * - * @return the user key as a string - */ - public String getKey() { - return key.stringValue(); - } - - /** - * Returns the value of the IP property for the user, if set. - * - * @return a string or null - */ - public String getIp() { - return ip.stringValue(); - } - - /** - * Returns the value of the country property for the user, if set. - * - * @return a string or null - */ - public String getCountry() { - return country.stringValue(); - } - - /** - * Returns the value of the full name property for the user, if set. - * - * @return a string or null - */ - public String getName() { - return name.stringValue(); - } - - /** - * Returns the value of the first name property for the user, if set. - * - * @return a string or null - */ - public String getFirstName() { - return firstName.stringValue(); - } - - /** - * Returns the value of the last name property for the user, if set. - * - * @return a string or null - */ - public String getLastName() { - return lastName.stringValue(); - } - - /** - * Returns the value of the email property for the user, if set. - * - * @return a string or null - */ - public String getEmail() { - return email.stringValue(); - } +public abstract class LDUser { + private LDUser() {} - /** - * Returns the value of the avatar property for the user, if set. - * - * @return a string or null - */ - public String getAvatar() { - return avatar.stringValue(); - } - - /** - * Returns true if this user was marked anonymous. - * - * @return true for an anonymous user - */ - public boolean isAnonymous() { - return anonymous.booleanValue(); - } - - /** - * Gets the value of a user attribute, if present. - *

    - * This can be either a built-in attribute or a custom one. It returns the value using the {@link LDValue} - * type, which can have any type that is supported in JSON. If the attribute does not exist, it returns - * {@link LDValue#ofNull()}. - * - * @param attribute the attribute to get - * @return the attribute value or {@link LDValue#ofNull()}; will never be an actual null reference - */ - public LDValue getAttribute(UserAttribute attribute) { - if (attribute.isBuiltIn()) { - return attribute.builtInGetter.apply(this); - } else { - return custom == null ? LDValue.ofNull() : LDValue.normalize(custom.get(attribute)); - } - } - - /** - * Returns an enumeration of all custom attribute names that were set for this user. - * - * @return the custom attribute names - */ - public Iterable getCustomAttributes() { - return custom == null ? Collections.emptyList() : custom.keySet(); - } - - /** - * Returns an enumeration of all attributes that were marked private for this user. - *

    - * This does not include any attributes that were globally marked private in your SDK configuration. - * - * @return the names of private attributes for this user - */ - public Iterable getPrivateAttributes() { - return privateAttributeNames == null ? Collections.emptyList() : privateAttributeNames; - } - - /** - * Tests whether an attribute has been marked private for this user. - * - * @param attribute a built-in or custom attribute - * @return true if the attribute was marked private on a per-user level - */ - public boolean isAttributePrivate(UserAttribute attribute) { - return privateAttributeNames != null && privateAttributeNames.contains(attribute); - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o instanceof LDUser) { - LDUser ldUser = (LDUser) o; - return Objects.equals(key, ldUser.key) && - 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); - } - return false; - } - - @Override - public int hashCode() { - return Objects.hash(key, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); - } - - @Override - public String toString() { - return "LDUser(" + JsonSerialization.serialize(this) + ")"; - } - /** * A builder that helps construct {@link LDUser} objects. Builder * calls can be chained, enabling the following pattern: @@ -251,17 +51,7 @@ public String toString() { *

    */ public static class Builder { - private String key; - 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 privateAttributes; + private final ContextBuilder builder; /** * Creates a builder with the specified key. @@ -269,26 +59,16 @@ public static class Builder { * @param key the unique key for this user */ public Builder(String key) { - this.key = key; + this.builder = LDContext.builder(key); } /** - * Creates a builder based on an existing user. + * Creates a builder based on an existing context. * - * @param user an existing {@code LDUser} + * @param context an existing {@code LDContext} */ - public Builder(LDUser user) { - this.key = user.key.stringValue(); - this.ip = user.ip.stringValue(); - this.firstName = user.firstName.stringValue(); - this.lastName = user.lastName.stringValue(); - this.email = user.email.stringValue(); - this.name = user.name.stringValue(); - this.avatar = user.avatar.stringValue(); - this.anonymous = user.anonymous.isNull() ? null : user.anonymous.booleanValue(); - this.country = user.country.stringValue(); - this.custom = user.custom == null ? null : new HashMap<>(user.custom); - this.privateAttributes = user.privateAttributeNames == null ? null : new HashSet<>(user.privateAttributeNames); + public Builder(LDContext context) { + this.builder = LDContext.builderFromContext(context); } /** @@ -298,7 +78,7 @@ public Builder(LDUser user) { * @return the builder */ public Builder key(String s) { - this.key = s; + builder.key(s); return this; } @@ -309,7 +89,7 @@ public Builder key(String s) { * @return the builder */ public Builder ip(String s) { - this.ip = s; + builder.set("ip", s); return this; } @@ -320,7 +100,7 @@ public Builder ip(String s) { * @return the builder */ public Builder privateIp(String s) { - addPrivate(UserAttribute.IP); + builder.privateAttributes("ip"); return ip(s); } @@ -333,7 +113,7 @@ public Builder privateIp(String s) { * @return the builder */ public Builder country(String s) { - this.country = s; + builder.set("country", s); return this; } @@ -347,7 +127,7 @@ public Builder country(String s) { * @return the builder */ public Builder privateCountry(String s) { - addPrivate(UserAttribute.COUNTRY); + builder.privateAttributes("country"); return country(s); } @@ -358,7 +138,7 @@ public Builder privateCountry(String s) { * @return the builder */ public Builder firstName(String firstName) { - this.firstName = firstName; + builder.set("firstName", firstName); return this; } @@ -370,7 +150,7 @@ public Builder firstName(String firstName) { * @return the builder */ public Builder privateFirstName(String firstName) { - addPrivate(UserAttribute.FIRST_NAME); + builder.privateAttributes("firstName"); return firstName(firstName); } @@ -382,7 +162,7 @@ public Builder privateFirstName(String firstName) { * @return the builder */ public Builder anonymous(boolean anonymous) { - this.anonymous = anonymous; + builder.anonymous(anonymous); return this; } @@ -393,7 +173,7 @@ public Builder anonymous(boolean anonymous) { * @return the builder */ public Builder lastName(String lastName) { - this.lastName = lastName; + builder.set("lastName", lastName); return this; } @@ -404,7 +184,7 @@ public Builder lastName(String lastName) { * @return the builder */ public Builder privateLastName(String lastName) { - addPrivate(UserAttribute.LAST_NAME); + builder.privateAttributes("lastName"); return lastName(lastName); } @@ -416,7 +196,7 @@ public Builder privateLastName(String lastName) { * @return the builder */ public Builder name(String name) { - this.name = name; + builder.name(name); return this; } @@ -427,7 +207,7 @@ public Builder name(String name) { * @return the builder */ public Builder privateName(String name) { - addPrivate(UserAttribute.NAME); + builder.privateAttributes("name"); return name(name); } @@ -438,7 +218,7 @@ public Builder privateName(String name) { * @return the builder */ public Builder avatar(String avatar) { - this.avatar = avatar; + builder.set("avatar", avatar); return this; } @@ -449,7 +229,7 @@ public Builder avatar(String avatar) { * @return the builder */ public Builder privateAvatar(String avatar) { - addPrivate(UserAttribute.AVATAR); + builder.privateAttributes("avatar"); return avatar(avatar); } @@ -461,7 +241,7 @@ public Builder privateAvatar(String avatar) { * @return the builder */ public Builder email(String email) { - this.email = email; + builder.set("email", email); return this; } @@ -472,7 +252,7 @@ public Builder email(String email) { * @return the builder */ public Builder privateEmail(String email) { - addPrivate(UserAttribute.EMAIL); + builder.privateAttributes("email"); return email(email); } @@ -538,17 +318,7 @@ public Builder custom(String k, boolean b) { * @return the builder */ public Builder custom(String k, LDValue v) { - if (k != null) { - return customInternal(UserAttribute.forName(k), v); - } - return this; - } - - private Builder customInternal(UserAttribute a, LDValue v) { - if (custom == null) { - custom = new HashMap<>(); - } - custom.put(a, LDValue.normalize(v)); + builder.set(k, v); return this; } @@ -563,7 +333,8 @@ private Builder customInternal(UserAttribute a, LDValue v) { * @return the builder */ public Builder privateCustom(String k, String v) { - return privateCustom(k, LDValue.of(v)); + builder.privateAttributes(k); + return custom(k, v); } /** @@ -577,7 +348,8 @@ public Builder privateCustom(String k, String v) { * @return the builder */ public Builder privateCustom(String k, int n) { - return privateCustom(k, LDValue.of(n)); + builder.privateAttributes(k); + return custom(k, n); } /** @@ -591,7 +363,8 @@ public Builder privateCustom(String k, int n) { * @return the builder */ public Builder privateCustom(String k, double n) { - return privateCustom(k, LDValue.of(n)); + builder.privateAttributes(k); + return custom(k, n); } /** @@ -605,7 +378,8 @@ public Builder privateCustom(String k, double n) { * @return the builder */ public Builder privateCustom(String k, boolean b) { - return privateCustom(k, LDValue.of(b)); + builder.privateAttributes(k); + return custom(k, b); } /** @@ -619,28 +393,17 @@ public Builder privateCustom(String k, boolean b) { * @return the builder */ public Builder privateCustom(String k, LDValue v) { - if (k != null) { - UserAttribute a = UserAttribute.forName(k); - addPrivate(a); - return customInternal(a, v); - } - return this; + builder.privateAttributes(k); + return custom(k, v); } - void addPrivate(UserAttribute attribute) { - if (privateAttributes == null) { - privateAttributes = new LinkedHashSet<>(); // LinkedHashSet preserves insertion order, for test determinacy - } - privateAttributes.add(attribute); - } - /** - * Builds the configured {@link LDUser} object. + * Builds the configured {@link LDContext} object. * - * @return the {@link LDUser} configured by this builder + * @return the {@link LDContext} configured by this builder */ - public LDUser build() { - return new LDUser(this); + public LDContext build() { + return builder.build(); } } } diff --git a/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java deleted file mode 100644 index 1099670..0000000 --- a/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.launchdarkly.sdk; - -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; - -import static com.launchdarkly.sdk.Helpers.readNullableString; - -final class LDUserTypeAdapter extends TypeAdapter{ - static final LDUserTypeAdapter INSTANCE = new LDUserTypeAdapter(); - - @Override - public LDUser read(JsonReader reader) throws IOException { - LDUser.Builder builder = new LDUser.Builder((String)null); - reader.beginObject(); - while (reader.peek() != JsonToken.END_OBJECT) { - String key = reader.nextName(); - switch (key) { // COVERAGE: may have spurious "branches missed" warning, see https://stackoverflow.com/questions/28013717/eclemma-branch-coverage-for-switch-7-of-19-missed - case "key": - builder.key(readNullableString(reader)); - break; - case "ip": - builder.ip(readNullableString(reader)); - break; - case "email": - builder.email(readNullableString(reader)); - break; - case "name": - builder.name(readNullableString(reader)); - break; - case "avatar": - builder.avatar(readNullableString(reader)); - break; - case "firstName": - builder.firstName(readNullableString(reader)); - break; - case "lastName": - builder.lastName(readNullableString(reader)); - break; - case "country": - builder.country(readNullableString(reader)); - break; - case "anonymous": - if (reader.peek() == JsonToken.NULL) { - reader.nextNull(); - } else { - builder.anonymous(reader.nextBoolean()); - } - break; - case "custom": - if (reader.peek() == JsonToken.NULL) { - reader.nextNull(); - } else { - reader.beginObject(); - while (reader.peek() != JsonToken.END_OBJECT) { - String customKey = reader.nextName(); - LDValue customValue = LDValueTypeAdapter.INSTANCE.read(reader); - builder.custom(customKey, customValue); - } - reader.endObject(); - } - break; - case "privateAttributeNames": - if (reader.peek() == JsonToken.NULL) { - reader.nextNull(); - } else { - reader.beginArray(); - while (reader.peek() != JsonToken.END_ARRAY) { - String name = reader.nextString(); - builder.addPrivate(UserAttribute.forName(name)); - } - reader.endArray(); - } - break; - default: - // ignore unknown top-level keys - reader.skipValue(); - } - } - reader.endObject(); - return builder.build(); - } - - @Override - public void write(JsonWriter writer, LDUser user) throws IOException { - // Currently, the field layout of LDUser does match the JSON representation, so Gson's default - // reflection mechanism would work, but we've implemented serialization manually here to avoid - // relying on that implementation detail and also to reduce the overhead of reflection. - // - // Note that this is not the serialization we use in analytics events; the SDK has a different - // custom serializer for that, in order to implement the private attribute redaction logic. - // The logic here is for serializing LDUser in the format that is used when you pass a user to - // the SDK as an *input*, i.e. if you are passing it to front-end JS code. - - writer.beginObject(); - for (UserAttribute attr: UserAttribute.BUILTINS.values()) { - LDValue value = user.getAttribute(attr); - if (!value.isNull()) { - writer.name(attr.getName()); - LDValueTypeAdapter.INSTANCE.write(writer, value); - } - } - boolean hasCustom = false; - for (UserAttribute attr: user.getCustomAttributes()) { - if (!hasCustom) { - hasCustom = true; - writer.name("custom"); - writer.beginObject(); - } - writer.name(attr.getName()); - LDValueTypeAdapter.INSTANCE.write(writer, user.getAttribute(attr)); - } - if (hasCustom) { - writer.endObject(); - } - boolean hasPrivate = false; - for (UserAttribute attr: user.getPrivateAttributes()) { - if (!hasPrivate) { - hasPrivate = true; - writer.name("privateAttributeNames"); - writer.beginArray(); - } - writer.value(attr.getName()); - } - if (hasPrivate) { - writer.endArray(); - } - writer.endObject(); - } -} diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index 5c8be85..3784ecc 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -22,7 +22,7 @@ * values (a JSON array), or a map of strings to {@link LDValue} values (a JSON object). It is easily * convertible to standard Java types. *

    - * This can be used to represent complex data in a user custom attribute (see {@link LDUser.Builder#custom(String, LDValue)}), + * This can be used to represent complex data in a context attribute (see {@link ContextBuilder#set(String, LDValue)}), * or to get a feature flag value that uses a complex type or that does not always use the same * type (see the client's {@code jsonValueVariation} methods). *

    diff --git a/src/main/java/com/launchdarkly/sdk/UserAttribute.java b/src/main/java/com/launchdarkly/sdk/UserAttribute.java deleted file mode 100644 index 45276c6..0000000 --- a/src/main/java/com/launchdarkly/sdk/UserAttribute.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.launchdarkly.sdk; - -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.sdk.json.JsonSerializable; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -/** - * Represents a built-in or custom attribute name supported by {@link LDUser}. - *

    - * This abstraction helps to distinguish attribute names from other {@link String} values, and also - * improves efficiency in feature flag data structures and evaluations because built-in attributes - * always reuse the same instances. - *

    - * For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference - * guides on Setting user attributes - * and Targeting users. - */ -@JsonAdapter(UserAttribute.UserAttributeTypeAdapter.class) -public final class UserAttribute implements JsonSerializable { - /** - * Represents the user key attribute. - */ - public static final UserAttribute KEY = new UserAttribute("key", new Function() { - public LDValue apply(LDUser u) { - return u.key; - } - }); - - /** - * Represents the IP address attribute. - */ - public static final UserAttribute IP = new UserAttribute("ip", new Function() { - public LDValue apply(LDUser u) { - return u.ip; - } - }); - - /** - * Represents the user key attribute. - */ - public static final UserAttribute EMAIL = new UserAttribute("email", new Function() { - public LDValue apply(LDUser u) { - return u.email; - } - }); - - /** - * Represents the full name attribute. - */ - public static final UserAttribute NAME = new UserAttribute("name", new Function() { - public LDValue apply(LDUser u) { - return u.name; - } - }); - - /** - * Represents the avatar URL attribute. - */ - public static final UserAttribute AVATAR = new UserAttribute("avatar", new Function() { - public LDValue apply(LDUser u) { - return u.avatar; - } - }); - - /** - * Represents the first name attribute. - */ - public static final UserAttribute FIRST_NAME = new UserAttribute("firstName", new Function() { - public LDValue apply(LDUser u) { - return u.firstName; - } - }); - - /** - * Represents the last name attribute. - */ - public static final UserAttribute LAST_NAME = new UserAttribute("lastName", new Function() { - public LDValue apply(LDUser u) { - return u.lastName; - } - }); - - /** - * Represents the country attribute. - */ - public static final UserAttribute COUNTRY = new UserAttribute("country", new Function() { - public LDValue apply(LDUser u) { - return u.country; - } - }); - - /** - * Represents the anonymous attribute. - */ - public static final UserAttribute ANONYMOUS = new UserAttribute("anonymous", new Function() { - public LDValue apply(LDUser u) { - return u.anonymous; - } - }); - - - static final Map BUILTINS; - static { - BUILTINS = new HashMap<>(); - for (UserAttribute a: new UserAttribute[] { KEY, IP, EMAIL, NAME, AVATAR, FIRST_NAME, LAST_NAME, COUNTRY, ANONYMOUS }) { - BUILTINS.put(a.getName(), a); - } - } - - private final String name; - final Function builtInGetter; - - private UserAttribute(String name, Function builtInGetter) { - this.name = name; - this.builtInGetter = builtInGetter; - } - - /** - * Returns a UserAttribute instance for the specified attribute name. - *

    - * For built-in attributes, the same instances are always reused and {@link #isBuiltIn()} will - * return true. For custom attributes, a new instance is created and {@link #isBuiltIn()} will - * return false. - * - * @param name the attribute name - * @return a {@link UserAttribute} - */ - public static UserAttribute forName(String name) { - UserAttribute a = BUILTINS.get(name); - return a != null ? a : new UserAttribute(name, null); - } - - /** - * Returns the case-sensitive attribute name. - * - * @return the attribute name - */ - public String getName() { - return name; - } - - /** - * Returns true for a built-in attribute or false for a custom attribute. - * - * @return true if it is a built-in attribute - */ - public boolean isBuiltIn() { - return builtInGetter != null; - } - - @Override - public boolean equals(Object other) { - if (other instanceof UserAttribute) { - UserAttribute o = (UserAttribute)other; - if (isBuiltIn() || o.isBuiltIn()) { - return this == o; // faster comparison since built-in instances are interned - } - return name.equals(o.name); - } - return false; - } - - @Override - public int hashCode() { - return isBuiltIn() ? super.hashCode() : name.hashCode(); - } - - @Override - public String toString() { - return name; - } - - static final class UserAttributeTypeAdapter extends TypeAdapter{ - @Override - public UserAttribute read(JsonReader reader) throws IOException { - // Unfortunately, JsonReader.nextString() does not actually enforce that the value is a string - switch (reader.peek()) { - case STRING: - return UserAttribute.forName(reader.nextString()); - default: - throw new IllegalStateException("expected string for UserAttribute"); - // IllegalStateException seems to be what Gson parsing methods normally use for wrong types - } - } - - @Override - public void write(JsonWriter writer, UserAttribute value) throws IOException { - writer.value(value.getName()); - } - } -} diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index 7d6df2c..18ea074 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -7,9 +7,7 @@ import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDContext; -import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.UserAttribute; import java.lang.reflect.Method; import java.lang.reflect.Type; @@ -156,9 +154,7 @@ static Iterable> getDeserializableClasses() { knownDeserializableClasses.add(EvaluationReason.class); knownDeserializableClasses.add(EvaluationDetail.class); knownDeserializableClasses.add(LDContext.class); - knownDeserializableClasses.add(LDUser.class); knownDeserializableClasses.add(LDValue.class); - knownDeserializableClasses.add(UserAttribute.class); // Use reflection to find any additional classes provided by an SDK; if there are none or if // this fails for any reason, don't worry about it diff --git a/src/main/java/com/launchdarkly/sdk/json/LDGson.java b/src/main/java/com/launchdarkly/sdk/json/LDGson.java index 4212e75..dd59ec2 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDGson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDGson.java @@ -11,7 +11,7 @@ import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; -import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import java.io.IOException; @@ -45,7 +45,7 @@ *

    * This causes Gson to use the correct JSON representation logic (the same that would be used by * {@link JsonSerialization}) for any types that have the SDK's {@link JsonSerializable} marker - * interface, such as {@link LDUser} and {@link LDValue}, regardless of whether they are the + * interface, such as {@link LDContext} and {@link LDValue}, regardless of whether they are the * top-level object being serialized or are contained in something else such as a collection. It * does not affect Gson's behavior for any other classes. *

    diff --git a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java index cbe1669..4cf1499 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java @@ -11,7 +11,7 @@ import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.module.SimpleModule; -import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import java.io.IOException; @@ -31,7 +31,7 @@ *

    * This causes Jackson to use the correct JSON representation logic (the same that would be used by * {@link JsonSerialization}) for any types that have the SDK's {@link JsonSerializable} marker - * interface, such as {@link LDUser} and {@link LDValue}, regardless of whether they are the + * interface, such as {@link LDContext} and {@link LDValue}, regardless of whether they are the * top-level object being serialized or are contained in something else such as a collection. It * does not affect Jackson's behavior for any other classes. *

    diff --git a/src/test/java/com/launchdarkly/sdk/LDUserTest.java b/src/test/java/com/launchdarkly/sdk/LDUserTest.java index 7eb0230..d145a34 100644 --- a/src/test/java/com/launchdarkly/sdk/LDUserTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDUserTest.java @@ -1,134 +1,92 @@ package com.launchdarkly.sdk; -import com.launchdarkly.sdk.json.JsonSerialization; - import org.junit.Test; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import static com.launchdarkly.sdk.Helpers.transform; import static com.launchdarkly.sdk.TestHelpers.setFromIterable; -import static java.util.Arrays.asList; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.contains; -import static org.hamcrest.Matchers.emptyIterable; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; 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.assertTrue; @SuppressWarnings("javadoc") public class LDUserTest extends BaseTest { private static enum OptionalStringAttributes { ip( - new Function() { public String apply(LDUser u) { return u.getIp(); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.ip(s); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateIp(s); } }), firstName( - new Function() { public String apply(LDUser u) { return u.getFirstName(); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.firstName(s); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateFirstName(s); } }), lastName( - new Function() { public String apply(LDUser u) { return u.getLastName(); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.lastName(s); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateLastName(s); } }), email( - new Function() { public String apply(LDUser u) { return u.getEmail(); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.email(s); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateEmail(s); } }), name( - new Function() { public String apply(LDUser u) { return u.getName(); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.name(s); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateName(s); } }), avatar( - new Function() { public String apply(LDUser u) { return u.getAvatar(); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.avatar(s); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateAvatar(s); } }), country( - new Function() { public String apply(LDUser u) { return u.getCountry(); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.country(s); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateCountry(s); } }); - final UserAttribute attribute; - final Function getter; + final String attribute; final BiFunction setter; final BiFunction privateSetter; private OptionalStringAttributes( - Function getter, BiFunction setter, BiFunction privateSetter ) { - this.attribute = UserAttribute.forName(this.name()); - this.getter = getter; + final String name = this.name(); + this.attribute = name; this.setter = setter; this.privateSetter = privateSetter; } }; - @Test - public void simpleConstructorSetsKey() { - LDUser user = new LDUser("key"); - assertEquals("key", user.getKey()); - assertEquals(LDValue.of("key"), user.getAttribute(UserAttribute.KEY)); - for (OptionalStringAttributes a: OptionalStringAttributes.values()) { - assertNull(a.toString(), a.getter.apply(user)); - assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a.attribute)); - } - assertThat(user.isAnonymous(), is(false)); - assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); - assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); - assertThat(user.getCustomAttributes(), emptyIterable()); - assertThat(user.getPrivateAttributes(), emptyIterable()); - } - @Test public void builderSetsOptionalStringAttribute() { for (OptionalStringAttributes a: OptionalStringAttributes.values()) { String value = "value-of-" + a.name(); LDUser.Builder builder = new LDUser.Builder("key"); a.setter.apply(builder, value); - LDUser user = builder.build(); + LDContext user = builder.build(); for (OptionalStringAttributes a1: OptionalStringAttributes.values()) { if (a1 == a) { - assertEquals(a.toString(), value, a1.getter.apply(user)); - assertEquals(a.toString(), LDValue.of(value), user.getAttribute(a1.attribute)); + assertEquals(a.toString(), LDValue.of(value), user.getValue(a1.attribute)); } else { - assertNull(a.toString(), a1.getter.apply(user)); - assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a1.attribute)); + assertEquals(a.toString(), LDValue.ofNull(), user.getValue(a1.attribute)); } } assertThat(user.isAnonymous(), is(false)); - assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); - assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); - assertThat(user.getCustomAttributes(), emptyIterable()); - assertThat(user.getPrivateAttributes(), emptyIterable()); - assertFalse(user.isAttributePrivate(a.attribute)); + assertThat(user.getPrivateAttributeCount(), equalTo(0)); } } @@ -138,22 +96,17 @@ public void builderSetsPrivateOptionalStringAttribute() { String value = "value-of-" + a.name(); LDUser.Builder builder = new LDUser.Builder("key"); a.privateSetter.apply(builder, value); - LDUser user = builder.build(); + LDContext user = builder.build(); for (OptionalStringAttributes a1: OptionalStringAttributes.values()) { if (a1 == a) { - assertEquals(a.toString(), value, a1.getter.apply(user)); - assertEquals(a.toString(), LDValue.of(value), user.getAttribute(a1.attribute)); + assertEquals(a.toString(), LDValue.of(value), user.getValue(a1.attribute)); } else { - assertNull(a.toString(), a1.getter.apply(user)); - assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a1.attribute)); + assertEquals(a.toString(), LDValue.ofNull(), user.getValue(a1.attribute)); } } assertThat(user.isAnonymous(), is(false)); - assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); - assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); - assertThat(user.getCustomAttributes(), emptyIterable()); - assertThat(user.getPrivateAttributes(), contains(a.attribute)); - assertTrue(user.isAttributePrivate(a.attribute)); + assertThat(user.getPrivateAttributeCount(), equalTo(1)); + assertThat(user.getPrivateAttribute(0).toString(), equalTo(a.attribute)); } } @@ -164,7 +117,7 @@ public void builderSetsCustomAttributes() { floatValue = LDValue.of(2.5), stringValue = LDValue.of("x"), jsonValue = LDValue.buildArray().build(); - LDUser user = new LDUser.Builder("key") + LDContext user = new LDUser.Builder("key") .custom("custom-bool", boolValue.booleanValue()) .custom("custom-int", intValue.intValue()) .custom("custom-float", floatValue.floatValue()) @@ -173,29 +126,17 @@ public void builderSetsCustomAttributes() { .custom("custom-json", jsonValue) .build(); List names = Arrays.asList("custom-bool", "custom-int", "custom-float", "custom-double", "custom-string", "custom-json"); - assertThat(user.getAttribute(UserAttribute.forName("custom-bool")), equalTo(boolValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-int")), equalTo(intValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-float")), equalTo(floatValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-double")), equalTo(floatValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-string")), equalTo(stringValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-json")), equalTo(jsonValue)); - assertThat(setFromIterable(user.getCustomAttributes()), - equalTo(setFromIterable(transform(names, new Function() { - public UserAttribute apply(String s) { return UserAttribute.forName(s); } - })))); - assertThat(user.getPrivateAttributes(), emptyIterable()); - for (String name: names) { - assertThat(name, user.isAttributePrivate(UserAttribute.forName(name)), is(false)); - } + assertThat(user.getValue("custom-bool"), equalTo(boolValue)); + assertThat(user.getValue("custom-int"), equalTo(intValue)); + assertThat(user.getValue("custom-float"), equalTo(floatValue)); + assertThat(user.getValue("custom-double"), equalTo(floatValue)); + assertThat(user.getValue("custom-string"), equalTo(stringValue)); + assertThat(user.getValue("custom-json"), equalTo(jsonValue)); + assertThat(setFromIterable(user.getCustomAttributeNames()), + equalTo(setFromIterable(names))); + assertThat(user.getPrivateAttributeCount(), equalTo(0)); } - @Test - public void customAttributeWithNullNameIsIgnored() { - LDUser user1 = new LDUser.Builder("key").custom(null, "1").privateCustom(null, "2").custom("a", "2").build(); - LDUser user2 = new LDUser.Builder("key").custom("a", "2").build(); - assertEquals(user2, user1); - } - @Test public void builderSetsPrivateCustomAttributes() { LDValue boolValue = LDValue.of(true), @@ -203,7 +144,7 @@ public void builderSetsPrivateCustomAttributes() { floatValue = LDValue.of(2.5), stringValue = LDValue.of("x"), jsonValue = LDValue.buildArray().build(); - LDUser user = new LDUser.Builder("key") + LDContext user = new LDUser.Builder("key") .privateCustom("custom-bool", boolValue.booleanValue()) .privateCustom("custom-int", intValue.intValue()) .privateCustom("custom-float", floatValue.floatValue()) @@ -212,25 +153,23 @@ public void builderSetsPrivateCustomAttributes() { .privateCustom("custom-json", jsonValue) .build(); List names = Arrays.asList("custom-bool", "custom-int", "custom-float", "custom-double", "custom-string", "custom-json"); - assertThat(user.getAttribute(UserAttribute.forName("custom-bool")), equalTo(boolValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-int")), equalTo(intValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-float")), equalTo(floatValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-double")), equalTo(floatValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-string")), equalTo(stringValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-json")), equalTo(jsonValue)); - assertThat(setFromIterable(user.getCustomAttributes()), - equalTo(setFromIterable(transform(names, new Function() { - public UserAttribute apply(String s) { return UserAttribute.forName(s); } - })))); - assertThat(setFromIterable(user.getPrivateAttributes()), equalTo(setFromIterable(user.getCustomAttributes()))); - for (String name: names) { - assertThat(name, user.isAttributePrivate(UserAttribute.forName(name)), is(true)); + assertThat(user.getValue("custom-bool"), equalTo(boolValue)); + assertThat(user.getValue("custom-int"), equalTo(intValue)); + assertThat(user.getValue("custom-float"), equalTo(floatValue)); + assertThat(user.getValue("custom-double"), equalTo(floatValue)); + assertThat(user.getValue("custom-string"), equalTo(stringValue)); + assertThat(user.getValue("custom-json"), equalTo(jsonValue)); + assertThat(setFromIterable(user.getCustomAttributeNames()), + equalTo(setFromIterable(names))); + assertThat(user.getPrivateAttributeCount(), equalTo(names.size())); + for (int i = 0; i < names.size(); i++) { + assertThat(user.getPrivateAttribute(i).toString(), equalTo(names.get(i))); } } @Test - public void canCopyUserWithBuilder() { - LDUser user = new LDUser.Builder("key") + public void canCopyContextWithBuilder() { + LDContext user = new LDUser.Builder("key") .ip("127.0.0.1") .firstName("Bob") .lastName("Loblaw") @@ -242,88 +181,19 @@ public void canCopyUserWithBuilder() { .build(); assertEquals(user, new LDUser.Builder(user).build()); - LDUser userWithPrivateAttrs = new LDUser.Builder("key").privateName("x").build(); + LDContext userWithPrivateAttrs = new LDUser.Builder("key").privateName("x").build(); assertEquals(userWithPrivateAttrs, new LDUser.Builder(userWithPrivateAttrs).build()); - LDUser userWithCustomAttrs = new LDUser.Builder("key").custom("org", "LaunchDarkly").build(); + LDContext userWithCustomAttrs = new LDUser.Builder("key").custom("org", "LaunchDarkly").build(); assertEquals(userWithCustomAttrs, new LDUser.Builder(userWithCustomAttrs).build()); } @Test public void canSetAnonymous() { - LDUser user1 = new LDUser.Builder("key").anonymous(true).build(); + LDContext user1 = new LDUser.Builder("key").anonymous(true).build(); assertThat(user1.isAnonymous(), is(true)); - assertThat(user1.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.of(true))); - LDUser user2 = new LDUser.Builder("key").anonymous(false).build(); + LDContext user2 = new LDUser.Builder("key").anonymous(false).build(); assertThat(user2.isAnonymous(), is(false)); - assertThat(user2.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.of(false))); - } - - @Test - public void getAttributeGetsBuiltInAttributeEvenIfCustomAttrHasSameName() { - LDUser user = new LDUser.Builder("key") - .name("Jane") - .custom("name", "Joan") - .build(); - assertEquals(LDValue.of("Jane"), user.getAttribute(UserAttribute.forName("name"))); - } - - @Test - public void equalValuesAreEqual() { - String key = "key"; - List> testValues = new ArrayList<>(); - testValues.add(asList(new LDUser(key), new LDUser(key))); - testValues.add(asList(new LDUser("key2"), new LDUser("key2"))); - for (OptionalStringAttributes a: OptionalStringAttributes.values()) { - List equalValues = new ArrayList<>(); - for (int i = 0; i < 2; i++) { - LDUser.Builder builder = new LDUser.Builder(key); - a.setter.apply(builder, "x"); - equalValues.add(builder.build()); - } - testValues.add(equalValues); - List equalValuesPrivate = new ArrayList<>(); - for (int i = 0; i < 2; i++) { - LDUser.Builder builder = new LDUser.Builder(key); - a.privateSetter.apply(builder, "x"); - equalValuesPrivate.add(builder.build()); - } - testValues.add(equalValuesPrivate); - } - for (boolean anonValue: new boolean[] { true, false }) { - List equalValues = new ArrayList<>(); - for (int i = 0; i < 2; i++) { - equalValues.add(new LDUser.Builder(key).anonymous(anonValue).build()); - } - testValues.add(equalValues); - } - for (String attrName: new String[] { "custom1", "custom2" }) { - LDValue[] values = new LDValue[] { LDValue.of(true), LDValue.of(false) }; - for (LDValue attrValue: values) { - List equalValues = new ArrayList<>(); - for (int i = 0; i < 2; i++) { - LDUser.Builder builder = new LDUser.Builder(key).custom(attrName, attrValue); - equalValues.add(builder.build()); - } - testValues.add(equalValues); - } - List equalValues = new ArrayList<>(); - for (int i = 0; i < 2; i++) { - LDUser.Builder builder = new LDUser.Builder(key).privateCustom(attrName, values[0]); - equalValues.add(builder.build()); - } - testValues.add(equalValues); - } - TestHelpers.doEqualityTests(testValues); - - assertNotEquals(null, new LDUser("userkey")); - assertNotEquals("userkey", new LDUser("userkey")); - } - - @Test - public void simpleStringRepresentation() { - LDUser user = new LDUser.Builder("userkey").name("x").build(); - assertEquals("LDUser(" + JsonSerialization.serialize(user) + ")", user.toString()); } } diff --git a/src/test/java/com/launchdarkly/sdk/TestHelpers.java b/src/test/java/com/launchdarkly/sdk/TestHelpers.java index 7389cc8..76bc790 100644 --- a/src/test/java/com/launchdarkly/sdk/TestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/TestHelpers.java @@ -11,10 +11,6 @@ @SuppressWarnings("javadoc") public class TestHelpers { // Provided only because UserAttribute.BUILTINS isn't public - public static Iterable builtInAttributes() { - return UserAttribute.BUILTINS.values(); - } - public static List listFromIterable(Iterable it) { List list = new ArrayList<>(); for (T t: it) { diff --git a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java deleted file mode 100644 index 24932f4..0000000 --- a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.launchdarkly.sdk; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; - -import static com.launchdarkly.sdk.TestHelpers.builtInAttributes; -import static java.util.Arrays.asList; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -@SuppressWarnings("javadoc") -public class UserAttributeTest extends BaseTest { - @Test - public void keyAttribute() { - assertEquals("key", UserAttribute.KEY.getName()); - assertTrue(UserAttribute.KEY.isBuiltIn()); - } - - @Test - public void ipAttribute() { - assertEquals("ip", UserAttribute.IP.getName()); - assertTrue(UserAttribute.IP.isBuiltIn()); - } - - @Test - public void emailAttribute() { - assertEquals("email", UserAttribute.EMAIL.getName()); - assertTrue(UserAttribute.EMAIL.isBuiltIn()); - } - - @Test - public void nameAttribute() { - assertEquals("name", UserAttribute.NAME.getName()); - assertTrue(UserAttribute.NAME.isBuiltIn()); - } - - @Test - public void avatarAttribute() { - assertEquals("avatar", UserAttribute.AVATAR.getName()); - assertTrue(UserAttribute.AVATAR.isBuiltIn()); - } - - @Test - public void firstNameAttribute() { - assertEquals("firstName", UserAttribute.FIRST_NAME.getName()); - assertTrue(UserAttribute.FIRST_NAME.isBuiltIn()); - } - - @Test - public void lastNameAttribute() { - assertEquals("lastName", UserAttribute.LAST_NAME.getName()); - assertTrue(UserAttribute.LAST_NAME.isBuiltIn()); - } - - @Test - public void anonymousAttribute() { - assertEquals("anonymous", UserAttribute.ANONYMOUS.getName()); - assertTrue(UserAttribute.ANONYMOUS.isBuiltIn()); - } - - @Test - public void customAttribute() { - assertEquals("things", UserAttribute.forName("things").getName()); - assertFalse(UserAttribute.forName("things").isBuiltIn()); - } - - @Test - public void equalInstancesAreEqual() { - List> testValues = new ArrayList<>(); - for (UserAttribute attr: builtInAttributes()) { - testValues.add(asList(attr, UserAttribute.forName(attr.getName()))); - } - testValues.add(asList(UserAttribute.forName("custom1"), UserAttribute.forName("custom1"))); - testValues.add(asList(UserAttribute.forName("custom2"), UserAttribute.forName("custom2"))); - TestHelpers.doEqualityTests(testValues); - } - - @Test - public void simpleStringRepresentation() { - assertEquals("name", UserAttribute.NAME.toString()); - } -} diff --git a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java index 5f03611..ce9bf99 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java @@ -8,7 +8,7 @@ import com.google.gson.stream.JsonToken; import com.google.gson.stream.JsonWriter; import com.launchdarkly.sdk.EvaluationReason; -import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import org.junit.Test; @@ -61,20 +61,20 @@ public void valueMapToJsonElementMap() { @Test public void complexObjectToJsonTree() { - LDUser user = new LDUser.Builder("userkey").name("name") - .custom("attr1", LDValue.ofNull()) - .custom("attr2", LDValue.of(true)) - .custom("attr3", LDValue.of(false)) - .custom("attr4", LDValue.of(0)) - .custom("attr5", LDValue.of(1)) - .custom("attr6", LDValue.of("")) - .custom("attr7", LDValue.of("x")) - .custom("attr8", JsonTestHelpers.nestedArrayValue()) - .custom("attr9", JsonTestHelpers.nestedObjectValue()) + LDContext context = LDContext.builder("key").name("name") + .set("attr1", LDValue.ofNull()) + .set("attr2", LDValue.of(true)) + .set("attr3", LDValue.of(false)) + .set("attr4", LDValue.of(0)) + .set("attr5", LDValue.of(1)) + .set("attr6", LDValue.of("")) + .set("attr7", LDValue.of("x")) + .set("attr8", JsonTestHelpers.nestedArrayValue()) + .set("attr9", JsonTestHelpers.nestedObjectValue()) .build(); - JsonElement j = JsonTestHelpers.configureGson().toJsonTree(user); + JsonElement j = JsonTestHelpers.configureGson().toJsonTree(context); String js = JsonTestHelpers.gson.toJson(j); - assertEquals(LDValue.parse(JsonSerialization.serialize(user)), LDValue.parse(js)); + assertEquals(LDValue.parse(JsonSerialization.serialize(context)), LDValue.parse(js)); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java deleted file mode 100644 index 09c0c27..0000000 --- a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.launchdarkly.sdk.json; - -import com.launchdarkly.sdk.BaseTest; -import com.launchdarkly.sdk.LDUser; -import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.UserAttribute; - -import org.junit.Test; - -import static com.launchdarkly.sdk.TestHelpers.builtInAttributes; -import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize; -import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; -import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; -import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; - -@SuppressWarnings("javadoc") -public class LDUserJsonSerializationTest extends BaseTest { - @Test - public void minimalJsonEncoding() throws Exception { - LDUser user = new LDUser("userkey"); - verifySerializeAndDeserialize(user, "{\"key\":\"userkey\"}"); - - verifyDeserializeInvalidJson(LDUser.class, "3"); - verifyDeserializeInvalidJson(LDUser.class, "{\"key\":\"userkey\",\"name\":3"); - - verifySerialize((LDUser)null, "null"); - } - - @Test - public void defaultJsonEncodingWithoutPrivateAttributes() throws Exception { - LDUser user = new LDUser.Builder("userkey") - .ip("i") - .email("e") - .name("n") - .avatar("a") - .firstName("f") - .lastName("l") - .country("c") - .anonymous(true) - .custom("c1", "v1") - .custom("c2", "v2") - .build(); - LDValue expectedJson = LDValue.buildObject() - .put("key", "userkey") - .put("ip", "i") - .put("email", "e") - .put("name", "n") - .put("avatar", "a") - .put("firstName", "f") - .put("lastName", "l") - .put("country", "c") - .put("anonymous", true) - .put("custom", LDValue.buildObject().put("c1", "v1").put("c2", "v2").build()) - .build(); - verifySerializeAndDeserialize(user, expectedJson.toJsonString()); - } - - @Test - public void defaultJsonEncodingWithPrivateAttributes() throws Exception { - LDUser user = new LDUser.Builder("userkey") - .email("e") - .privateName("n") - .privateCountry("c") - .build(); - LDValue expectedJson = LDValue.buildObject() - .put("key", "userkey") - .put("email", "e") - .put("name", "n") - .put("country", "c") - .put("privateAttributeNames", LDValue.buildArray().add("name").add("country").build()) - .build(); - verifySerializeAndDeserialize(user, expectedJson.toJsonString()); - } - - @Test - public void explicitNullsAreIgnored() throws Exception { - LDUser user = new LDUser("userkey"); - StringBuilder sb = new StringBuilder().append("{\"key\":\"userkey\""); - for (UserAttribute a: builtInAttributes()) { - if (a != UserAttribute.KEY) { - sb.append(",\"").append(a.getName()).append("\":null"); - } - } - sb.append(",\"custom\":null,\"privateAttributeNames\":null}"); - verifyDeserialize(user, sb.toString()); - } - - @Test - public void unknownKeysAreIgnored() throws Exception { - LDUser user = new LDUser.Builder("userkey").name("x").build(); - verifyDeserialize(user, "{\"key\":\"userkey\",\"other\":true,\"name\":\"x\"}"); - } -} diff --git a/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java deleted file mode 100644 index 6ac6443..0000000 --- a/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.launchdarkly.sdk.json; - -import com.launchdarkly.sdk.BaseTest; -import com.launchdarkly.sdk.UserAttribute; - -import org.junit.Test; - -import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; -import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; - -@SuppressWarnings("javadoc") -public class UserAttributeJsonSerializationTest extends BaseTest { - @Test - public void userAttributeJsonSerializations() throws Exception { - verifySerializeAndDeserialize(UserAttribute.NAME, "\"name\""); - verifySerializeAndDeserialize(UserAttribute.forName("custom-attr"), "\"custom-attr\""); - - verifyDeserializeInvalidJson(UserAttribute.class, "3"); - } -} From 2abd2a0a10cd8a91214189aefef340190031d137 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 14 Oct 2022 09:29:53 -0700 Subject: [PATCH 85/91] test coverage --- src/test/java/com/launchdarkly/sdk/LDUserTest.java | 5 +++++ .../sdk/json/EvaluationReasonJsonSerializationTest.java | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/sdk/LDUserTest.java b/src/test/java/com/launchdarkly/sdk/LDUserTest.java index d145a34..7d7dd44 100644 --- a/src/test/java/com/launchdarkly/sdk/LDUserTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDUserTest.java @@ -167,6 +167,11 @@ public void builderSetsPrivateCustomAttributes() { } } + @Test + public void builderSetsKey() { + assertThat(new LDUser.Builder("a").key("b").build().getKey(), equalTo("b")); + } + @Test public void canCopyContextWithBuilder() { LDContext user = new LDUser.Builder("key") diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java index 5c60e1d..4512f0c 100644 --- a/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java @@ -42,10 +42,12 @@ public void reasonJsonSerializations() throws Exception { verifySerializeAndDeserialize(EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND), "{\"kind\":\"ERROR\",\"errorKind\":\"FLAG_NOT_FOUND\"}"); - // properties with defaults can be included + // properties with defaults can be included, explicit default values are ignored in parsing verifyDeserialize(EvaluationReason.fallthrough(false), "{\"kind\":\"FALLTHROUGH\",\"inExperiment\":false}"); verifyDeserialize(EvaluationReason.ruleMatch(1, "id", false), "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\",\"inExperiment\":false}"); + verifyDeserialize(EvaluationReason.ruleMatch(1, null, false), + "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":null}"); // unknown properties are ignored JsonTestHelpers.verifyDeserialize(EvaluationReason.off(), "{\"kind\":\"OFF\",\"other\":true}"); @@ -54,6 +56,7 @@ public void reasonJsonSerializations() throws Exception { verifyDeserializeInvalidJson(EvaluationReason.class, "{}"); // must have "kind" verifyDeserializeInvalidJson(EvaluationReason.class, "{\"kind\":3}"); verifyDeserializeInvalidJson(EvaluationReason.class, "{\"kind\":\"other\"}"); + verifyDeserializeInvalidJson(EvaluationReason.class, "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":3}"); } @Test From dec076a41b83d8e8a08b78abb29a23e659dad259 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 21 Oct 2022 15:13:45 -0700 Subject: [PATCH 86/91] update doc comment --- src/main/java/com/launchdarkly/sdk/LDUser.java | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index a47f738..452d70d 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -41,14 +41,11 @@ public abstract class LDUser { private LDUser() {} /** - * 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()
    -   * 
    + * A a mutable object that uses the Builder pattern to specify properties for a user + * context. + *

    + * This is a compatibility helper that has been retained to ease migration of code from the older + * "user" model to the newer "context" model. See {@link LDUser} for more information. */ public static class Builder { private final ContextBuilder builder; From 83a98416d4e688ccd73b80a7007a0f9d752ae34b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 18 Nov 2022 09:43:19 -0800 Subject: [PATCH 87/91] re-add LDUser type, add conversion to LDContext (#56) * re-add LDUser type, add conversion to LDContext * misc fixes * re-add test --- .../java/com/launchdarkly/sdk/Errors.java | 1 + .../java/com/launchdarkly/sdk/LDContext.java | 75 +++- .../java/com/launchdarkly/sdk/LDUser.java | 391 ++++++++++++++---- .../launchdarkly/sdk/LDUserTypeAdapter.java | 136 ++++++ .../com/launchdarkly/sdk/UserAttribute.java | 201 +++++++++ .../sdk/json/JsonSerialization.java | 4 + .../com/launchdarkly/sdk/LDContextTest.java | 53 +++ .../java/com/launchdarkly/sdk/LDUserTest.java | 209 +++++++--- .../com/launchdarkly/sdk/TestHelpers.java | 4 + .../launchdarkly/sdk/UserAttributeTest.java | 85 ++++ .../sdk/json/LDUserJsonSerializationTest.java | 93 +++++ .../UserAttributeJsonSerializationTest.java | 20 + 12 files changed, 1149 insertions(+), 123 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java create mode 100644 src/main/java/com/launchdarkly/sdk/UserAttribute.java create mode 100644 src/test/java/com/launchdarkly/sdk/UserAttributeTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java diff --git a/src/main/java/com/launchdarkly/sdk/Errors.java b/src/main/java/com/launchdarkly/sdk/Errors.java index 46235e7..d8322b5 100644 --- a/src/main/java/com/launchdarkly/sdk/Errors.java +++ b/src/main/java/com/launchdarkly/sdk/Errors.java @@ -8,6 +8,7 @@ private Errors() {} static final String ATTR_INVALID_ESCAPE = "attribute reference contained an escape character (~) that was not followed by 0 or 1"; + static final String CONTEXT_FROM_NULL_USER = "tried to use a null LDUser reference"; static final String CONTEXT_NO_KEY = "context key must not be null or empty"; static final String CONTEXT_KIND_CANNOT_BE_EMPTY = "context kind must not be empty in JSON"; static final String CONTEXT_KIND_CANNOT_BE_KIND = "\"kind\" is not a valid context kind"; diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java index 3d16047..30ab7ac 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContext.java +++ b/src/main/java/com/launchdarkly/sdk/LDContext.java @@ -4,12 +4,11 @@ import com.launchdarkly.sdk.json.JsonSerializable; import com.launchdarkly.sdk.json.JsonSerialization; -import java.io.UnsupportedEncodingException; -import java.net.URLEncoder; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -17,6 +16,10 @@ /** * A collection of attributes that can be referenced in flag evaluations and analytics events. *

    + * LDContext is the newer replacement for the previous, less flexible {@link LDUser} type. + * The current SDK still supports LDUser, but LDContext is now the preferred model and may + * entirely replace LDUser in the future. + *

    * To create an LDContext of a single kind, such as a user, you may use {@link #create(String)} * or {@link #create(ContextKind, String)} when only the key matters; or, to specify other * attributes, use {@link #builder(String)}. @@ -243,6 +246,72 @@ public static LDContext createMulti(LDContext... contexts) { return createMultiInternal(copied); } + /** + * Converts a user to an equivalent {@link LDContext} instance. + *

    + * This method is used by the SDK whenever an application passes a {@link LDUser} instance + * to methods such as {@code identify}. The SDK operates internally on the {@link LDContext} + * model, which is more flexible than the older LDUser model: an L User can always be converted + * to an LDContext, but not vice versa. The {@link ContextKind} of the resulting Context is + * {@link ContextKind#DEFAULT} ("user"). + *

    + * Because there is some overhead to this conversion, it is more efficient for applications to + * construct an LDContext and pass that to the SDK, rather than an LDUser. This is also recommended + * because the LDUser type may be removed in a future version of the SDK. + *

    + * If the {@code user} parameter is null, or if the user has a null key, the method returns an + * LDContext in an invalid state (see {@link LDContext#isValid()}). + * + * @param user an LDUser object + * @return an LDContext with the same attributes as the LDUser + */ + public static LDContext fromUser(LDUser user) { + if (user == null) { + return failed(Errors.CONTEXT_FROM_NULL_USER); + } + if (user.getKey() == null) { + return failed(Errors.CONTEXT_NO_KEY); + } + Map attributes = null; + for (UserAttribute a: UserAttribute.OPTIONAL_STRING_ATTRIBUTES) { + if (a == UserAttribute.NAME) { + continue; + } + LDValue value = user.getAttribute(a); + if (!value.isNull()) { + if (attributes == null) { + attributes = new HashMap<>(); + } + attributes.put(a.getName(), value); + } + } + if (user.custom != null && !user.custom.isEmpty()) { + if (attributes == null) { + attributes = new HashMap<>(); + } + for (Map.Entry kv: user.custom.entrySet()) { + attributes.put(kv.getKey().getName(), kv.getValue()); + } + } + List privateAttributes = null; + if (user.privateAttributeNames != null && !user.privateAttributeNames.isEmpty()) { + privateAttributes = new ArrayList<>(); + for (UserAttribute pa: user.privateAttributeNames) { + privateAttributes.add(AttributeRef.fromLiteral(pa.getName())); + } + } + return new LDContext( + ContextKind.DEFAULT, + null, + user.getKey(), + user.getKey(), + user.getName(), + attributes, + user.isAnonymous(), + privateAttributes + ); + } + /** * Creates a {@link ContextBuilder} for building an LDContext, initializing its {@code key} and setting * {@code kind} to {@link ContextKind#DEFAULT}. @@ -338,6 +407,8 @@ public static ContextMultiBuilder multiBuilder() { *

  • It is a multi-kind context that does not have any kinds. See {@link #createMulti(LDContext...)}.
  • *
  • It is a multi-kind context where the same kind appears more than once.
  • *
  • It is a multi-kind context where at least one of the nested LDContexts has an error.
  • + *
  • It was created with {@link #fromUser(LDUser)} from a null LDUser reference, or from an + * LDUser that had a null key.
  • * *

    * In any of these cases, {@link #isValid()} will return false, and {@link #getError()} diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index 452d70d..3107013 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -1,54 +1,268 @@ package com.launchdarkly.sdk; +import com.google.gson.annotations.JsonAdapter; +import com.launchdarkly.sdk.json.JsonSerializable; +import com.launchdarkly.sdk.json.JsonSerialization; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import static java.util.Collections.unmodifiableMap; +import static java.util.Collections.unmodifiableSet; + /** - * Contains legacy methods for constructing simple evaluation contexts, using the older LaunchDarkly - * SDK model for user properties. + * A collection of attributes that can affect flag evaluation, usually corresponding to a user of your application. *

    - * The SDK now uses the type {@link LDContext} to represent an evaluation context that might - * represent a user, or some other kind of entity, or multiple kinds. In older SDK versions, - * this was limited to one kind and was represented by the type {@code LDUser}. This differed from - * LDContext in several ways: - *

      - *
    • There was always a single implicit context kind of "user".
    • - *
    • Unlike LDContext where only a few attributes such as {@link ContextBuilder#key(String)} - * and {@link ContextBuilder#name(String)} have special behavior, the user model defined many - * other built-in attributes such as {@code email} which, like {@code name}, were constrained to - * only allow string values. These had specific setter methods in {@link LDUser.Builder}.
    • - *
    + * The only mandatory property is the {@code key}, which must uniquely identify each user; this could be a username + * or email address for authenticated users, or a session ID for anonymous users. All other built-in properties are + * optional. You may also define custom properties with arbitrary names and values. *

    - * The LDUser class now exists only as a container for {@link LDUser.Builder}, which has been - * modified to be a wrapper for {@link ContextBuilder}. This allows code that used the older - * older model to still work with minor adjustments. + * For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference + * guides on Setting user attributes + * and Targeting users. *

    - * For any code that still uses this builder, the significant differences from older SDK - * versions are: - *

      - *
    • The concrete type being constructed is {@link LDContext}, so you will need to update - * any part of your code that referred to LDUser as a concrete type.
    • - *
    • The SDK no longer supports setting the key to an empty string. If you do this, - * the returned LDContext will be invalid (as indicated by {@link LDContext#isValid()}) and - * the SDK will refuse to use it for evaluations or events.
    • - *
    • Previously, the {@link LDUser.Builder#anonymous(boolean)} property had three states: - * true, false, or undefined/null. Undefined/null and false were functionally the same in terms - * of the LaunchDarkly dashboard/indexing behavior, but they were represented differently in - * JSON and could behave differently if referenced in a flag rule (an undefined/null value - * would not match "anonymous is false"). Now, the property is a simple boolean defaulting to - * false, and the undefined state is the same as false.
    • - *
    • The {@code secondary} attribute no longer exists.
    • - *
    + * LaunchDarkly defines a standard JSON encoding for user objects, used by the JavaScript SDK and also in analytics + * events. {@link LDUser} can be converted to and from JSON in any of these ways: + *
      + *
    1. With {@link 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}. + *
    */ -public abstract class LDUser { - private LDUser() {} +@JsonAdapter(LDUserTypeAdapter.class) +public class LDUser implements JsonSerializable { + // 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. + final LDValue key; + final LDValue ip; + final LDValue email; + final LDValue name; + final LDValue avatar; + final LDValue firstName; + final LDValue lastName; + final boolean anonymous; + final LDValue country; + final Map custom; + Set privateAttributeNames; + + protected LDUser(Builder builder) { + this.key = LDValue.of(builder.key); + this.ip = LDValue.of(builder.ip); + this.country = LDValue.of(builder.country); + 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; + this.custom = builder.custom == null ? null : unmodifiableMap(builder.custom); + this.privateAttributeNames = builder.privateAttributes == null ? null : unmodifiableSet(builder.privateAttributes); + } + + /** + * 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.ip = this.email = this.name = this.avatar = this.firstName = this.lastName = this.country = + LDValue.ofNull(); + this.anonymous = false; + this.custom = null; + this.privateAttributeNames = null; + } + + /** + * Returns the user's unique key. + * + * @return the user key as a string + */ + public String getKey() { + return key.stringValue(); + } + + /** + * Returns the value of the IP property for the user, if set. + * + * @return a string or null + */ + public String getIp() { + return ip.stringValue(); + } + + /** + * Returns the value of the country property for the user, if set. + * + * @return a string or null + */ + public String getCountry() { + return country.stringValue(); + } + + /** + * Returns the value of the full name property for the user, if set. + * + * @return a string or null + */ + public String getName() { + return name.stringValue(); + } + + /** + * Returns the value of the first name property for the user, if set. + * + * @return a string or null + */ + public String getFirstName() { + return firstName.stringValue(); + } + + /** + * Returns the value of the last name property for the user, if set. + * + * @return a string or null + */ + public String getLastName() { + return lastName.stringValue(); + } + + /** + * Returns the value of the email property for the user, if set. + * + * @return a string or null + */ + public String getEmail() { + return email.stringValue(); + } /** - * A a mutable object that uses the Builder pattern to specify properties for a user - * context. + * Returns the value of the avatar property for the user, if set. + * + * @return a string or null + */ + public String getAvatar() { + return avatar.stringValue(); + } + + /** + * Returns true if this user was marked anonymous. + * + * @return true for an anonymous user + */ + public boolean isAnonymous() { + return anonymous; + } + + /** + * Gets the value of a user attribute, if present. + *

    + * This can be either a built-in attribute or a custom one. It returns the value using the {@link LDValue} + * type, which can have any type that is supported in JSON. If the attribute does not exist, it returns + * {@link LDValue#ofNull()}. + * + * @param attribute the attribute to get + * @return the attribute value or {@link LDValue#ofNull()}; will never be an actual null reference + */ + public LDValue getAttribute(UserAttribute attribute) { + if (attribute.isBuiltIn()) { + return attribute.builtInGetter.apply(this); + } else { + return custom == null ? LDValue.ofNull() : LDValue.normalize(custom.get(attribute)); + } + } + + /** + * Returns an enumeration of all custom attribute names that were set for this user. + * + * @return the custom attribute names + */ + public Iterable getCustomAttributes() { + return custom == null ? Collections.emptyList() : custom.keySet(); + } + + /** + * Returns an enumeration of all attributes that were marked private for this user. *

    - * This is a compatibility helper that has been retained to ease migration of code from the older - * "user" model to the newer "context" model. See {@link LDUser} for more information. + * This does not include any attributes that were globally marked private in your SDK configuration. + * + * @return the names of private attributes for this user + */ + public Iterable getPrivateAttributes() { + return privateAttributeNames == null ? Collections.emptyList() : privateAttributeNames; + } + + /** + * Tests whether an attribute has been marked private for this user. + * + * @param attribute a built-in or custom attribute + * @return true if the attribute was marked private on a per-user level + */ + public boolean isAttributePrivate(UserAttribute attribute) { + return privateAttributeNames != null && privateAttributeNames.contains(attribute); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o instanceof LDUser) { + LDUser ldUser = (LDUser) o; + return Objects.equals(key, ldUser.key) && + 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(country, ldUser.country) && + anonymous == ldUser.anonymous && + Objects.equals(custom, ldUser.custom) && + Objects.equals(privateAttributeNames, ldUser.privateAttributeNames); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(key, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); + } + + @Override + public String toString() { + return "LDUser(" + JsonSerialization.serialize(this) + ")"; + } + + /** + * 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 final ContextBuilder builder; + private String key; + private String ip; + private String firstName; + private String lastName; + private String email; + private String name; + private String avatar; + private String country; + private boolean anonymous = false; + private Map custom; + private Set privateAttributes; /** * Creates a builder with the specified key. @@ -56,16 +270,26 @@ public static class Builder { * @param key the unique key for this user */ public Builder(String key) { - this.builder = LDContext.builder(key); + this.key = key; } /** - * Creates a builder based on an existing context. + * Creates a builder based on an existing user. * - * @param context an existing {@code LDContext} + * @param user an existing {@code LDUser} */ - public Builder(LDContext context) { - this.builder = LDContext.builderFromContext(context); + public Builder(LDUser user) { + this.key = user.key.stringValue(); + this.ip = user.ip.stringValue(); + this.firstName = user.firstName.stringValue(); + this.lastName = user.lastName.stringValue(); + this.email = user.email.stringValue(); + this.name = user.name.stringValue(); + this.avatar = user.avatar.stringValue(); + this.anonymous = user.anonymous; + this.country = user.country.stringValue(); + this.custom = user.custom == null ? null : new HashMap<>(user.custom); + this.privateAttributes = user.privateAttributeNames == null ? null : new HashSet<>(user.privateAttributeNames); } /** @@ -75,7 +299,7 @@ public Builder(LDContext context) { * @return the builder */ public Builder key(String s) { - builder.key(s); + this.key = s; return this; } @@ -86,7 +310,7 @@ public Builder key(String s) { * @return the builder */ public Builder ip(String s) { - builder.set("ip", s); + this.ip = s; return this; } @@ -97,7 +321,7 @@ public Builder ip(String s) { * @return the builder */ public Builder privateIp(String s) { - builder.privateAttributes("ip"); + addPrivate(UserAttribute.IP); return ip(s); } @@ -110,7 +334,7 @@ public Builder privateIp(String s) { * @return the builder */ public Builder country(String s) { - builder.set("country", s); + this.country = s; return this; } @@ -124,7 +348,7 @@ public Builder country(String s) { * @return the builder */ public Builder privateCountry(String s) { - builder.privateAttributes("country"); + addPrivate(UserAttribute.COUNTRY); return country(s); } @@ -135,7 +359,7 @@ public Builder privateCountry(String s) { * @return the builder */ public Builder firstName(String firstName) { - builder.set("firstName", firstName); + this.firstName = firstName; return this; } @@ -147,7 +371,7 @@ public Builder firstName(String firstName) { * @return the builder */ public Builder privateFirstName(String firstName) { - builder.privateAttributes("firstName"); + addPrivate(UserAttribute.FIRST_NAME); return firstName(firstName); } @@ -159,7 +383,7 @@ public Builder privateFirstName(String firstName) { * @return the builder */ public Builder anonymous(boolean anonymous) { - builder.anonymous(anonymous); + this.anonymous = anonymous; return this; } @@ -170,7 +394,7 @@ public Builder anonymous(boolean anonymous) { * @return the builder */ public Builder lastName(String lastName) { - builder.set("lastName", lastName); + this.lastName = lastName; return this; } @@ -181,7 +405,7 @@ public Builder lastName(String lastName) { * @return the builder */ public Builder privateLastName(String lastName) { - builder.privateAttributes("lastName"); + addPrivate(UserAttribute.LAST_NAME); return lastName(lastName); } @@ -193,7 +417,7 @@ public Builder privateLastName(String lastName) { * @return the builder */ public Builder name(String name) { - builder.name(name); + this.name = name; return this; } @@ -204,7 +428,7 @@ public Builder name(String name) { * @return the builder */ public Builder privateName(String name) { - builder.privateAttributes("name"); + addPrivate(UserAttribute.NAME); return name(name); } @@ -215,7 +439,7 @@ public Builder privateName(String name) { * @return the builder */ public Builder avatar(String avatar) { - builder.set("avatar", avatar); + this.avatar = avatar; return this; } @@ -226,7 +450,7 @@ public Builder avatar(String avatar) { * @return the builder */ public Builder privateAvatar(String avatar) { - builder.privateAttributes("avatar"); + addPrivate(UserAttribute.AVATAR); return avatar(avatar); } @@ -238,7 +462,7 @@ public Builder privateAvatar(String avatar) { * @return the builder */ public Builder email(String email) { - builder.set("email", email); + this.email = email; return this; } @@ -249,7 +473,7 @@ public Builder email(String email) { * @return the builder */ public Builder privateEmail(String email) { - builder.privateAttributes("email"); + addPrivate(UserAttribute.EMAIL); return email(email); } @@ -315,7 +539,17 @@ public Builder custom(String k, boolean b) { * @return the builder */ public Builder custom(String k, LDValue v) { - builder.set(k, v); + if (k != null) { + return customInternal(UserAttribute.forName(k), v); + } + return this; + } + + private Builder customInternal(UserAttribute a, LDValue v) { + if (custom == null) { + custom = new HashMap<>(); + } + custom.put(a, LDValue.normalize(v)); return this; } @@ -330,8 +564,7 @@ public Builder custom(String k, LDValue v) { * @return the builder */ public Builder privateCustom(String k, String v) { - builder.privateAttributes(k); - return custom(k, v); + return privateCustom(k, LDValue.of(v)); } /** @@ -345,8 +578,7 @@ public Builder privateCustom(String k, String v) { * @return the builder */ public Builder privateCustom(String k, int n) { - builder.privateAttributes(k); - return custom(k, n); + return privateCustom(k, LDValue.of(n)); } /** @@ -360,8 +592,7 @@ public Builder privateCustom(String k, int n) { * @return the builder */ public Builder privateCustom(String k, double n) { - builder.privateAttributes(k); - return custom(k, n); + return privateCustom(k, LDValue.of(n)); } /** @@ -375,8 +606,7 @@ public Builder privateCustom(String k, double n) { * @return the builder */ public Builder privateCustom(String k, boolean b) { - builder.privateAttributes(k); - return custom(k, b); + return privateCustom(k, LDValue.of(b)); } /** @@ -390,17 +620,28 @@ public Builder privateCustom(String k, boolean b) { * @return the builder */ public Builder privateCustom(String k, LDValue v) { - builder.privateAttributes(k); - return custom(k, v); + if (k != null) { + UserAttribute a = UserAttribute.forName(k); + addPrivate(a); + return customInternal(a, v); + } + return this; } + void addPrivate(UserAttribute attribute) { + if (privateAttributes == null) { + privateAttributes = new LinkedHashSet<>(); // LinkedHashSet preserves insertion order, for test determinacy + } + privateAttributes.add(attribute); + } + /** - * Builds the configured {@link LDContext} object. + * Builds the configured {@link LDUser} object. * - * @return the {@link LDContext} configured by this builder + * @return the {@link LDUser} configured by this builder */ - public LDContext build() { - return builder.build(); + public LDUser build() { + return new LDUser(this); } } } diff --git a/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java new file mode 100644 index 0000000..c560762 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java @@ -0,0 +1,136 @@ +package com.launchdarkly.sdk; + +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; + +import static com.launchdarkly.sdk.Helpers.readNullableString; + +final class LDUserTypeAdapter extends TypeAdapter{ + static final LDUserTypeAdapter INSTANCE = new LDUserTypeAdapter(); + + @Override + public LDUser read(JsonReader reader) throws IOException { + LDUser.Builder builder = new LDUser.Builder((String)null); + reader.beginObject(); + while (reader.peek() != JsonToken.END_OBJECT) { + String key = reader.nextName(); + switch (key) { // COVERAGE: may have spurious "branches missed" warning, see https://stackoverflow.com/questions/28013717/eclemma-branch-coverage-for-switch-7-of-19-missed + case "key": + builder.key(readNullableString(reader)); + break; + case "ip": + builder.ip(readNullableString(reader)); + break; + case "email": + builder.email(readNullableString(reader)); + break; + case "name": + builder.name(readNullableString(reader)); + break; + case "avatar": + builder.avatar(readNullableString(reader)); + break; + case "firstName": + builder.firstName(readNullableString(reader)); + break; + case "lastName": + builder.lastName(readNullableString(reader)); + break; + case "country": + builder.country(readNullableString(reader)); + break; + case "anonymous": + if (reader.peek() == JsonToken.NULL) { + reader.nextNull(); + } else { + builder.anonymous(reader.nextBoolean()); + } + break; + case "custom": + if (reader.peek() == JsonToken.NULL) { + reader.nextNull(); + } else { + reader.beginObject(); + while (reader.peek() != JsonToken.END_OBJECT) { + String customKey = reader.nextName(); + LDValue customValue = LDValueTypeAdapter.INSTANCE.read(reader); + builder.custom(customKey, customValue); + } + reader.endObject(); + } + break; + case "privateAttributeNames": + if (reader.peek() == JsonToken.NULL) { + reader.nextNull(); + } else { + reader.beginArray(); + while (reader.peek() != JsonToken.END_ARRAY) { + String name = reader.nextString(); + builder.addPrivate(UserAttribute.forName(name)); + } + reader.endArray(); + } + break; + default: + // ignore unknown top-level keys + reader.skipValue(); + } + } + reader.endObject(); + return builder.build(); + } + + @Override + public void write(JsonWriter writer, LDUser user) throws IOException { + // Currently, the field layout of LDUser does match the JSON representation, so Gson's default + // reflection mechanism would work, but we've implemented serialization manually here to avoid + // relying on that implementation detail and also to reduce the overhead of reflection. + // + // Note that this is not the serialization we use in analytics events; the SDK has a different + // custom serializer for that, in order to implement the private attribute redaction logic. + // The logic here is for serializing LDUser in the format that is used when you pass a user to + // the SDK as an *input*, i.e. if you are passing it to front-end JS code. + + writer.beginObject(); + for (UserAttribute attr: UserAttribute.BUILTINS.values()) { + if (attr == UserAttribute.ANONYMOUS && !user.isAnonymous()) { + continue; // anonymous: false value doesn't need to be serialized + } + LDValue value = user.getAttribute(attr); + if (!value.isNull()) { + writer.name(attr.getName()); + LDValueTypeAdapter.INSTANCE.write(writer, value); + } + } + boolean hasCustom = false; + for (UserAttribute attr: user.getCustomAttributes()) { + if (!hasCustom) { + hasCustom = true; + writer.name("custom"); + writer.beginObject(); + } + writer.name(attr.getName()); + LDValueTypeAdapter.INSTANCE.write(writer, user.getAttribute(attr)); + } + if (hasCustom) { + writer.endObject(); + } + boolean hasPrivate = false; + for (UserAttribute attr: user.getPrivateAttributes()) { + if (!hasPrivate) { + hasPrivate = true; + writer.name("privateAttributeNames"); + writer.beginArray(); + } + writer.value(attr.getName()); + } + if (hasPrivate) { + writer.endArray(); + } + writer.endObject(); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/UserAttribute.java b/src/main/java/com/launchdarkly/sdk/UserAttribute.java new file mode 100644 index 0000000..e02060f --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/UserAttribute.java @@ -0,0 +1,201 @@ +package com.launchdarkly.sdk; + +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.sdk.json.JsonSerializable; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * Represents a built-in or custom attribute name supported by {@link LDUser}. + *

    + * Application code rarely needs to use this type; it is used internally by the SDK for + * efficiency in flag evaluations. It can also be used as a reference for the constant + * names of built-in attributes such as {@link #EMAIL}. However, in the newer + * {@link LDContext} model, there are very few reserved attribute names, so the + * equivalent of {@link #EMAIL} would simply be a custom attribute called "email". + *

    + * For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference + * guides on Setting user attributes + * and Targeting users. + */ +@JsonAdapter(UserAttribute.UserAttributeTypeAdapter.class) +public final class UserAttribute implements JsonSerializable { + /** + * Represents the user key attribute. + */ + public static final UserAttribute KEY = new UserAttribute("key", new Function() { + public LDValue apply(LDUser u) { + return u.key; + } + }); + + /** + * Represents the IP address attribute. + */ + public static final UserAttribute IP = new UserAttribute("ip", new Function() { + public LDValue apply(LDUser u) { + return u.ip; + } + }); + + /** + * Represents the user key attribute. + */ + public static final UserAttribute EMAIL = new UserAttribute("email", new Function() { + public LDValue apply(LDUser u) { + return u.email; + } + }); + + /** + * Represents the full name attribute. + */ + public static final UserAttribute NAME = new UserAttribute("name", new Function() { + public LDValue apply(LDUser u) { + return u.name; + } + }); + + /** + * Represents the avatar URL attribute. + */ + public static final UserAttribute AVATAR = new UserAttribute("avatar", new Function() { + public LDValue apply(LDUser u) { + return u.avatar; + } + }); + + /** + * Represents the first name attribute. + */ + public static final UserAttribute FIRST_NAME = new UserAttribute("firstName", new Function() { + public LDValue apply(LDUser u) { + return u.firstName; + } + }); + + /** + * Represents the last name attribute. + */ + public static final UserAttribute LAST_NAME = new UserAttribute("lastName", new Function() { + public LDValue apply(LDUser u) { + return u.lastName; + } + }); + + /** + * Represents the country attribute. + */ + public static final UserAttribute COUNTRY = new UserAttribute("country", new Function() { + public LDValue apply(LDUser u) { + return u.country; + } + }); + + /** + * Represents the anonymous attribute. + */ + public static final UserAttribute ANONYMOUS = new UserAttribute("anonymous", new Function() { + public LDValue apply(LDUser u) { + return LDValue.of(u.anonymous); + } + }); + + + static final Map BUILTINS; + static { + BUILTINS = new HashMap<>(); + for (UserAttribute a: new UserAttribute[] { KEY, IP, EMAIL, NAME, AVATAR, FIRST_NAME, LAST_NAME, COUNTRY, ANONYMOUS }) { + BUILTINS.put(a.getName(), a); + } + } + static final UserAttribute[] OPTIONAL_STRING_ATTRIBUTES = + new UserAttribute[] { IP, EMAIL, NAME, AVATAR, FIRST_NAME, LAST_NAME, COUNTRY }; + + private final String name; + final Function builtInGetter; + + private UserAttribute(String name, Function builtInGetter) { + this.name = name; + this.builtInGetter = builtInGetter; + } + + /** + * Returns a UserAttribute instance for the specified attribute name. + *

    + * For built-in attributes, the same instances are always reused and {@link #isBuiltIn()} will + * return true. For custom attributes, a new instance is created and {@link #isBuiltIn()} will + * return false. + * + * @param name the attribute name + * @return a {@link UserAttribute} + */ + public static UserAttribute forName(String name) { + UserAttribute a = BUILTINS.get(name); + return a != null ? a : new UserAttribute(name, null); + } + + /** + * Returns the case-sensitive attribute name. + * + * @return the attribute name + */ + public String getName() { + return name; + } + + /** + * Returns true for a built-in attribute or false for a custom attribute. + * + * @return true if it is a built-in attribute + */ + public boolean isBuiltIn() { + return builtInGetter != null; + } + + @Override + public boolean equals(Object other) { + if (other instanceof UserAttribute) { + UserAttribute o = (UserAttribute)other; + if (isBuiltIn() || o.isBuiltIn()) { + return this == o; // faster comparison since built-in instances are interned + } + return name.equals(o.name); + } + return false; + } + + @Override + public int hashCode() { + return isBuiltIn() ? super.hashCode() : name.hashCode(); + } + + @Override + public String toString() { + return name; + } + + static final class UserAttributeTypeAdapter extends TypeAdapter{ + @Override + public UserAttribute read(JsonReader reader) throws IOException { + // Unfortunately, JsonReader.nextString() does not actually enforce that the value is a string + switch (reader.peek()) { + case STRING: + return UserAttribute.forName(reader.nextString()); + default: + throw new IllegalStateException("expected string for UserAttribute"); + // IllegalStateException seems to be what Gson parsing methods normally use for wrong types + } + } + + @Override + public void write(JsonWriter writer, UserAttribute value) throws IOException { + writer.value(value.getName()); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java index 18ea074..7d6df2c 100644 --- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -7,7 +7,9 @@ import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; import java.lang.reflect.Method; import java.lang.reflect.Type; @@ -154,7 +156,9 @@ static Iterable> getDeserializableClasses() { knownDeserializableClasses.add(EvaluationReason.class); knownDeserializableClasses.add(EvaluationDetail.class); knownDeserializableClasses.add(LDContext.class); + knownDeserializableClasses.add(LDUser.class); knownDeserializableClasses.add(LDValue.class); + knownDeserializableClasses.add(UserAttribute.class); // Use reflection to find any additional classes provided by an SDK; if there are none or if // this fails for any reason, don't worry about it diff --git a/src/test/java/com/launchdarkly/sdk/LDContextTest.java b/src/test/java/com/launchdarkly/sdk/LDContextTest.java index e6899c6..58ea409 100644 --- a/src/test/java/com/launchdarkly/sdk/LDContextTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDContextTest.java @@ -361,4 +361,57 @@ static List> makeValues() { values.add(asList(LDContext.createMulti(), LDContext.createMulti())); // invalid with a different error return values; } + + @Test + public void contextFromUser() { + LDUser u1 = new LDUser.Builder("key") + .ip("127.0.0.1") + .firstName("Bob") + .lastName("Loblaw") + .email("bob@example.com") + .privateName("Bob Loblaw") + .avatar("image") + .country("US") + .anonymous(true) + .build(); + LDContext c1 = LDContext.fromUser(u1); + assertThat(c1, equalTo( + LDContext.builder(u1.getKey()) + .set("ip", u1.getIp()) + .set("firstName", u1.getFirstName()) + .set("lastName", u1.getLastName()) + .set("email", u1.getEmail()) + .set("name", u1.getName()) + .set("avatar", u1.getAvatar()) + .set("country", u1.getCountry()) + .privateAttributes("name") + .anonymous(true) + .build() + )); + + // test case where there were no built-in optional attrs, only custom + LDUser u2 = new LDUser.Builder("key") + .custom("c1", "v1") + .privateCustom("c2", "v2") + .build(); + LDContext c2 = LDContext.fromUser(u2); + assertThat(c2, equalTo( + LDContext.builder(u2.getKey()) + .set("c1", "v1") + .set("c2", "v2") + .privateAttributes("c2") + .build() + )); + } + + @Test + public void contextFromUserErrors() { + LDContext c1 = LDContext.fromUser(null); + assertThat(c1.isValid(), is(false)); + assertThat(c1.getError(), equalTo(Errors.CONTEXT_FROM_NULL_USER)); + + LDContext c2 = LDContext.fromUser(new LDUser((String)null)); + assertThat(c2.isValid(), is(false)); + assertThat(c2.getError(), equalTo(Errors.CONTEXT_NO_KEY)); +} } diff --git a/src/test/java/com/launchdarkly/sdk/LDUserTest.java b/src/test/java/com/launchdarkly/sdk/LDUserTest.java index 7d7dd44..678977a 100644 --- a/src/test/java/com/launchdarkly/sdk/LDUserTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDUserTest.java @@ -1,92 +1,132 @@ package com.launchdarkly.sdk; +import com.launchdarkly.sdk.json.JsonSerialization; + import org.junit.Test; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import static com.launchdarkly.sdk.Helpers.transform; import static com.launchdarkly.sdk.TestHelpers.setFromIterable; +import static java.util.Arrays.asList; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.emptyIterable; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; 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.assertTrue; @SuppressWarnings("javadoc") public class LDUserTest extends BaseTest { private static enum OptionalStringAttributes { ip( + new Function() { public String apply(LDUser u) { return u.getIp(); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.ip(s); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateIp(s); } }), firstName( + new Function() { public String apply(LDUser u) { return u.getFirstName(); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.firstName(s); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateFirstName(s); } }), lastName( + new Function() { public String apply(LDUser u) { return u.getLastName(); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.lastName(s); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateLastName(s); } }), email( + new Function() { public String apply(LDUser u) { return u.getEmail(); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.email(s); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateEmail(s); } }), name( + new Function() { public String apply(LDUser u) { return u.getName(); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.name(s); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateName(s); } }), avatar( + new Function() { public String apply(LDUser u) { return u.getAvatar(); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.avatar(s); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateAvatar(s); } }), country( + new Function() { public String apply(LDUser u) { return u.getCountry(); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.country(s); } }, new BiFunction() { public LDUser.Builder apply(LDUser.Builder b, String s) { return b.privateCountry(s); } }); - final String attribute; + final UserAttribute attribute; + final Function getter; final BiFunction setter; final BiFunction privateSetter; private OptionalStringAttributes( + Function getter, BiFunction setter, BiFunction privateSetter ) { - final String name = this.name(); - this.attribute = name; + this.attribute = UserAttribute.forName(this.name()); + this.getter = getter; this.setter = setter; this.privateSetter = privateSetter; } }; + @Test + public void simpleConstructorSetsKey() { + LDUser user = new LDUser("key"); + assertEquals("key", user.getKey()); + assertEquals(LDValue.of("key"), user.getAttribute(UserAttribute.KEY)); + for (OptionalStringAttributes a: OptionalStringAttributes.values()) { + assertNull(a.toString(), a.getter.apply(user)); + assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a.attribute)); + } + assertThat(user.isAnonymous(), is(false)); + assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); + assertThat(user.getCustomAttributes(), emptyIterable()); + assertThat(user.getPrivateAttributes(), emptyIterable()); + } + @Test public void builderSetsOptionalStringAttribute() { for (OptionalStringAttributes a: OptionalStringAttributes.values()) { String value = "value-of-" + a.name(); LDUser.Builder builder = new LDUser.Builder("key"); a.setter.apply(builder, value); - LDContext user = builder.build(); + LDUser user = builder.build(); for (OptionalStringAttributes a1: OptionalStringAttributes.values()) { if (a1 == a) { - assertEquals(a.toString(), LDValue.of(value), user.getValue(a1.attribute)); + assertEquals(a.toString(), value, a1.getter.apply(user)); + assertEquals(a.toString(), LDValue.of(value), user.getAttribute(a1.attribute)); } else { - assertEquals(a.toString(), LDValue.ofNull(), user.getValue(a1.attribute)); + assertNull(a.toString(), a1.getter.apply(user)); + assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a1.attribute)); } } assertThat(user.isAnonymous(), is(false)); - assertThat(user.getPrivateAttributeCount(), equalTo(0)); + assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); + assertThat(user.getCustomAttributes(), emptyIterable()); + assertThat(user.getPrivateAttributes(), emptyIterable()); + assertFalse(user.isAttributePrivate(a.attribute)); } } @@ -96,17 +136,21 @@ public void builderSetsPrivateOptionalStringAttribute() { String value = "value-of-" + a.name(); LDUser.Builder builder = new LDUser.Builder("key"); a.privateSetter.apply(builder, value); - LDContext user = builder.build(); + LDUser user = builder.build(); for (OptionalStringAttributes a1: OptionalStringAttributes.values()) { if (a1 == a) { - assertEquals(a.toString(), LDValue.of(value), user.getValue(a1.attribute)); + assertEquals(a.toString(), value, a1.getter.apply(user)); + assertEquals(a.toString(), LDValue.of(value), user.getAttribute(a1.attribute)); } else { - assertEquals(a.toString(), LDValue.ofNull(), user.getValue(a1.attribute)); + assertNull(a.toString(), a1.getter.apply(user)); + assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a1.attribute)); } } assertThat(user.isAnonymous(), is(false)); - assertThat(user.getPrivateAttributeCount(), equalTo(1)); - assertThat(user.getPrivateAttribute(0).toString(), equalTo(a.attribute)); + assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); + assertThat(user.getCustomAttributes(), emptyIterable()); + assertThat(user.getPrivateAttributes(), contains(a.attribute)); + assertTrue(user.isAttributePrivate(a.attribute)); } } @@ -117,7 +161,7 @@ public void builderSetsCustomAttributes() { floatValue = LDValue.of(2.5), stringValue = LDValue.of("x"), jsonValue = LDValue.buildArray().build(); - LDContext user = new LDUser.Builder("key") + LDUser user = new LDUser.Builder("key") .custom("custom-bool", boolValue.booleanValue()) .custom("custom-int", intValue.intValue()) .custom("custom-float", floatValue.floatValue()) @@ -126,17 +170,29 @@ public void builderSetsCustomAttributes() { .custom("custom-json", jsonValue) .build(); List names = Arrays.asList("custom-bool", "custom-int", "custom-float", "custom-double", "custom-string", "custom-json"); - assertThat(user.getValue("custom-bool"), equalTo(boolValue)); - assertThat(user.getValue("custom-int"), equalTo(intValue)); - assertThat(user.getValue("custom-float"), equalTo(floatValue)); - assertThat(user.getValue("custom-double"), equalTo(floatValue)); - assertThat(user.getValue("custom-string"), equalTo(stringValue)); - assertThat(user.getValue("custom-json"), equalTo(jsonValue)); - assertThat(setFromIterable(user.getCustomAttributeNames()), - equalTo(setFromIterable(names))); - assertThat(user.getPrivateAttributeCount(), equalTo(0)); + assertThat(user.getAttribute(UserAttribute.forName("custom-bool")), equalTo(boolValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-int")), equalTo(intValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-float")), equalTo(floatValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-double")), equalTo(floatValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-string")), equalTo(stringValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-json")), equalTo(jsonValue)); + assertThat(setFromIterable(user.getCustomAttributes()), + equalTo(setFromIterable(transform(names, new Function() { + public UserAttribute apply(String s) { return UserAttribute.forName(s); } + })))); + assertThat(user.getPrivateAttributes(), emptyIterable()); + for (String name: names) { + assertThat(name, user.isAttributePrivate(UserAttribute.forName(name)), is(false)); + } } + @Test + public void customAttributeWithNullNameIsIgnored() { + LDUser user1 = new LDUser.Builder("key").custom(null, "1").privateCustom(null, "2").custom("a", "2").build(); + LDUser user2 = new LDUser.Builder("key").custom("a", "2").build(); + assertEquals(user2, user1); + } + @Test public void builderSetsPrivateCustomAttributes() { LDValue boolValue = LDValue.of(true), @@ -144,7 +200,7 @@ public void builderSetsPrivateCustomAttributes() { floatValue = LDValue.of(2.5), stringValue = LDValue.of("x"), jsonValue = LDValue.buildArray().build(); - LDContext user = new LDUser.Builder("key") + LDUser user = new LDUser.Builder("key") .privateCustom("custom-bool", boolValue.booleanValue()) .privateCustom("custom-int", intValue.intValue()) .privateCustom("custom-float", floatValue.floatValue()) @@ -153,28 +209,25 @@ public void builderSetsPrivateCustomAttributes() { .privateCustom("custom-json", jsonValue) .build(); List names = Arrays.asList("custom-bool", "custom-int", "custom-float", "custom-double", "custom-string", "custom-json"); - assertThat(user.getValue("custom-bool"), equalTo(boolValue)); - assertThat(user.getValue("custom-int"), equalTo(intValue)); - assertThat(user.getValue("custom-float"), equalTo(floatValue)); - assertThat(user.getValue("custom-double"), equalTo(floatValue)); - assertThat(user.getValue("custom-string"), equalTo(stringValue)); - assertThat(user.getValue("custom-json"), equalTo(jsonValue)); - assertThat(setFromIterable(user.getCustomAttributeNames()), - equalTo(setFromIterable(names))); - assertThat(user.getPrivateAttributeCount(), equalTo(names.size())); - for (int i = 0; i < names.size(); i++) { - assertThat(user.getPrivateAttribute(i).toString(), equalTo(names.get(i))); + assertThat(user.getAttribute(UserAttribute.forName("custom-bool")), equalTo(boolValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-int")), equalTo(intValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-float")), equalTo(floatValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-double")), equalTo(floatValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-string")), equalTo(stringValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-json")), equalTo(jsonValue)); + assertThat(setFromIterable(user.getCustomAttributes()), + equalTo(setFromIterable(transform(names, new Function() { + public UserAttribute apply(String s) { return UserAttribute.forName(s); } + })))); + assertThat(setFromIterable(user.getPrivateAttributes()), equalTo(setFromIterable(user.getCustomAttributes()))); + for (String name: names) { + assertThat(name, user.isAttributePrivate(UserAttribute.forName(name)), is(true)); } } @Test - public void builderSetsKey() { - assertThat(new LDUser.Builder("a").key("b").build().getKey(), equalTo("b")); - } - - @Test - public void canCopyContextWithBuilder() { - LDContext user = new LDUser.Builder("key") + public void canCopyUserWithBuilder() { + LDUser user = new LDUser.Builder("key") .ip("127.0.0.1") .firstName("Bob") .lastName("Loblaw") @@ -186,19 +239,83 @@ public void canCopyContextWithBuilder() { .build(); assertEquals(user, new LDUser.Builder(user).build()); - LDContext userWithPrivateAttrs = new LDUser.Builder("key").privateName("x").build(); + LDUser userWithPrivateAttrs = new LDUser.Builder("key").privateName("x").build(); assertEquals(userWithPrivateAttrs, new LDUser.Builder(userWithPrivateAttrs).build()); - LDContext userWithCustomAttrs = new LDUser.Builder("key").custom("org", "LaunchDarkly").build(); + LDUser userWithCustomAttrs = new LDUser.Builder("key").custom("org", "LaunchDarkly").build(); assertEquals(userWithCustomAttrs, new LDUser.Builder(userWithCustomAttrs).build()); } @Test public void canSetAnonymous() { - LDContext user1 = new LDUser.Builder("key").anonymous(true).build(); + LDUser user1 = new LDUser.Builder("key").anonymous(true).build(); assertThat(user1.isAnonymous(), is(true)); + assertThat(user1.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.of(true))); - LDContext user2 = new LDUser.Builder("key").anonymous(false).build(); + LDUser user2 = new LDUser.Builder("key").anonymous(false).build(); assertThat(user2.isAnonymous(), is(false)); + assertThat(user2.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.of(false))); + } + + @Test + public void getAttributeGetsBuiltInAttributeEvenIfCustomAttrHasSameName() { + LDUser user = new LDUser.Builder("key") + .name("Jane") + .custom("name", "Joan") + .build(); + assertEquals(LDValue.of("Jane"), user.getAttribute(UserAttribute.forName("name"))); + } + + @Test + public void equalValuesAreEqual() { + String key = "key"; + List> testValues = new ArrayList<>(); + testValues.add(asList(new LDUser(key), new LDUser(key))); + testValues.add(asList(new LDUser("key2"), new LDUser("key2"))); + testValues.add(asList(new LDUser.Builder(key).anonymous(true).build(), + new LDUser.Builder(key).anonymous(true).build())); + for (OptionalStringAttributes a: OptionalStringAttributes.values()) { + List equalValues = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + LDUser.Builder builder = new LDUser.Builder(key); + a.setter.apply(builder, "x"); + equalValues.add(builder.build()); + } + testValues.add(equalValues); + List equalValuesPrivate = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + LDUser.Builder builder = new LDUser.Builder(key); + a.privateSetter.apply(builder, "x"); + equalValuesPrivate.add(builder.build()); + } + testValues.add(equalValuesPrivate); + } + for (String attrName: new String[] { "custom1", "custom2" }) { + LDValue[] values = new LDValue[] { LDValue.of(true), LDValue.of(false) }; + for (LDValue attrValue: values) { + List equalValues = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + LDUser.Builder builder = new LDUser.Builder(key).custom(attrName, attrValue); + equalValues.add(builder.build()); + } + testValues.add(equalValues); + } + List equalValues = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + LDUser.Builder builder = new LDUser.Builder(key).privateCustom(attrName, values[0]); + equalValues.add(builder.build()); + } + testValues.add(equalValues); + } + TestHelpers.doEqualityTests(testValues); + + assertNotEquals(null, new LDUser("userkey")); + assertNotEquals("userkey", new LDUser("userkey")); + } + + @Test + public void simpleStringRepresentation() { + LDUser user = new LDUser.Builder("userkey").name("x").build(); + assertEquals("LDUser(" + JsonSerialization.serialize(user) + ")", user.toString()); } -} +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/TestHelpers.java b/src/test/java/com/launchdarkly/sdk/TestHelpers.java index 76bc790..80d09c5 100644 --- a/src/test/java/com/launchdarkly/sdk/TestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/TestHelpers.java @@ -11,6 +11,10 @@ @SuppressWarnings("javadoc") public class TestHelpers { // Provided only because UserAttribute.BUILTINS isn't public + public static Iterable builtInAttributes() { + return UserAttribute.BUILTINS.values(); + } + public static List listFromIterable(Iterable it) { List list = new ArrayList<>(); for (T t: it) { diff --git a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java new file mode 100644 index 0000000..24932f4 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java @@ -0,0 +1,85 @@ +package com.launchdarkly.sdk; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static com.launchdarkly.sdk.TestHelpers.builtInAttributes; +import static java.util.Arrays.asList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class UserAttributeTest extends BaseTest { + @Test + public void keyAttribute() { + assertEquals("key", UserAttribute.KEY.getName()); + assertTrue(UserAttribute.KEY.isBuiltIn()); + } + + @Test + public void ipAttribute() { + assertEquals("ip", UserAttribute.IP.getName()); + assertTrue(UserAttribute.IP.isBuiltIn()); + } + + @Test + public void emailAttribute() { + assertEquals("email", UserAttribute.EMAIL.getName()); + assertTrue(UserAttribute.EMAIL.isBuiltIn()); + } + + @Test + public void nameAttribute() { + assertEquals("name", UserAttribute.NAME.getName()); + assertTrue(UserAttribute.NAME.isBuiltIn()); + } + + @Test + public void avatarAttribute() { + assertEquals("avatar", UserAttribute.AVATAR.getName()); + assertTrue(UserAttribute.AVATAR.isBuiltIn()); + } + + @Test + public void firstNameAttribute() { + assertEquals("firstName", UserAttribute.FIRST_NAME.getName()); + assertTrue(UserAttribute.FIRST_NAME.isBuiltIn()); + } + + @Test + public void lastNameAttribute() { + assertEquals("lastName", UserAttribute.LAST_NAME.getName()); + assertTrue(UserAttribute.LAST_NAME.isBuiltIn()); + } + + @Test + public void anonymousAttribute() { + assertEquals("anonymous", UserAttribute.ANONYMOUS.getName()); + assertTrue(UserAttribute.ANONYMOUS.isBuiltIn()); + } + + @Test + public void customAttribute() { + assertEquals("things", UserAttribute.forName("things").getName()); + assertFalse(UserAttribute.forName("things").isBuiltIn()); + } + + @Test + public void equalInstancesAreEqual() { + List> testValues = new ArrayList<>(); + for (UserAttribute attr: builtInAttributes()) { + testValues.add(asList(attr, UserAttribute.forName(attr.getName()))); + } + testValues.add(asList(UserAttribute.forName("custom1"), UserAttribute.forName("custom1"))); + testValues.add(asList(UserAttribute.forName("custom2"), UserAttribute.forName("custom2"))); + TestHelpers.doEqualityTests(testValues); + } + + @Test + public void simpleStringRepresentation() { + assertEquals("name", UserAttribute.NAME.toString()); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java new file mode 100644 index 0000000..09c0c27 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java @@ -0,0 +1,93 @@ +package com.launchdarkly.sdk.json; + +import com.launchdarkly.sdk.BaseTest; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; + +import org.junit.Test; + +import static com.launchdarkly.sdk.TestHelpers.builtInAttributes; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; + +@SuppressWarnings("javadoc") +public class LDUserJsonSerializationTest extends BaseTest { + @Test + public void minimalJsonEncoding() throws Exception { + LDUser user = new LDUser("userkey"); + verifySerializeAndDeserialize(user, "{\"key\":\"userkey\"}"); + + verifyDeserializeInvalidJson(LDUser.class, "3"); + verifyDeserializeInvalidJson(LDUser.class, "{\"key\":\"userkey\",\"name\":3"); + + verifySerialize((LDUser)null, "null"); + } + + @Test + public void defaultJsonEncodingWithoutPrivateAttributes() throws Exception { + LDUser user = new LDUser.Builder("userkey") + .ip("i") + .email("e") + .name("n") + .avatar("a") + .firstName("f") + .lastName("l") + .country("c") + .anonymous(true) + .custom("c1", "v1") + .custom("c2", "v2") + .build(); + LDValue expectedJson = LDValue.buildObject() + .put("key", "userkey") + .put("ip", "i") + .put("email", "e") + .put("name", "n") + .put("avatar", "a") + .put("firstName", "f") + .put("lastName", "l") + .put("country", "c") + .put("anonymous", true) + .put("custom", LDValue.buildObject().put("c1", "v1").put("c2", "v2").build()) + .build(); + verifySerializeAndDeserialize(user, expectedJson.toJsonString()); + } + + @Test + public void defaultJsonEncodingWithPrivateAttributes() throws Exception { + LDUser user = new LDUser.Builder("userkey") + .email("e") + .privateName("n") + .privateCountry("c") + .build(); + LDValue expectedJson = LDValue.buildObject() + .put("key", "userkey") + .put("email", "e") + .put("name", "n") + .put("country", "c") + .put("privateAttributeNames", LDValue.buildArray().add("name").add("country").build()) + .build(); + verifySerializeAndDeserialize(user, expectedJson.toJsonString()); + } + + @Test + public void explicitNullsAreIgnored() throws Exception { + LDUser user = new LDUser("userkey"); + StringBuilder sb = new StringBuilder().append("{\"key\":\"userkey\""); + for (UserAttribute a: builtInAttributes()) { + if (a != UserAttribute.KEY) { + sb.append(",\"").append(a.getName()).append("\":null"); + } + } + sb.append(",\"custom\":null,\"privateAttributeNames\":null}"); + verifyDeserialize(user, sb.toString()); + } + + @Test + public void unknownKeysAreIgnored() throws Exception { + LDUser user = new LDUser.Builder("userkey").name("x").build(); + verifyDeserialize(user, "{\"key\":\"userkey\",\"other\":true,\"name\":\"x\"}"); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java new file mode 100644 index 0000000..0420514 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/UserAttributeJsonSerializationTest.java @@ -0,0 +1,20 @@ +package com.launchdarkly.sdk.json; + +import com.launchdarkly.sdk.BaseTest; +import com.launchdarkly.sdk.UserAttribute; + +import org.junit.Test; + +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; + +@SuppressWarnings("javadoc") +public class UserAttributeJsonSerializationTest extends BaseTest { + @Test + public void userAttributeJsonSerializations() throws Exception { + verifySerializeAndDeserialize(UserAttribute.NAME, "\"name\""); + verifySerializeAndDeserialize(UserAttribute.forName("custom-attr"), "\"custom-attr\""); + + verifyDeserializeInvalidJson(UserAttribute.class, "3"); + } +} From 6893e642955866200c25e3aac8cabdf2389a6d5f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 18 Nov 2022 16:24:11 -0800 Subject: [PATCH 88/91] allow anonymous user with null key to be converted to a context --- .../com/launchdarkly/sdk/ContextBuilder.java | 2 +- .../java/com/launchdarkly/sdk/LDContext.java | 17 +++++++++++++---- .../com/launchdarkly/sdk/LDContextTest.java | 7 +++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/ContextBuilder.java b/src/main/java/com/launchdarkly/sdk/ContextBuilder.java index 39687a5..548bba3 100644 --- a/src/main/java/com/launchdarkly/sdk/ContextBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ContextBuilder.java @@ -189,7 +189,7 @@ public ContextBuilder anonymous(boolean anonymous) { *

    * The attribute name "_meta" is not allowed, because it has special meaning in the * JSON schema for contexts; any attempt to set an attribute with this name has no - * effect. + * effect. Also, any attempt to set an attribute with an empty or null name has no effect. *

    * Values that are JSON arrays or objects have special behavior when referenced in * flag/segment rules. diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java index 30ab7ac..6d6bb98 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContext.java +++ b/src/main/java/com/launchdarkly/sdk/LDContext.java @@ -269,8 +269,17 @@ public static LDContext fromUser(LDUser user) { if (user == null) { return failed(Errors.CONTEXT_FROM_NULL_USER); } - if (user.getKey() == null) { - return failed(Errors.CONTEXT_NO_KEY); + String key = user.getKey(); + if (key == null) { + if (user.isAnonymous()) { + // In the old user model, a user was able to have a null key for the special case + // where (in the Android SDK only) the user was anonymous and the SDK would generate a + // key for it. There is a different mechanism for this in the new Android SDK, but we + // will replace the null key with "" so the original context is valid. + key = ""; + } else { + return failed(Errors.CONTEXT_NO_KEY); + } } Map attributes = null; for (UserAttribute a: UserAttribute.OPTIONAL_STRING_ATTRIBUTES) { @@ -303,8 +312,8 @@ public static LDContext fromUser(LDUser user) { return new LDContext( ContextKind.DEFAULT, null, - user.getKey(), - user.getKey(), + key, + key, user.getName(), attributes, user.isAnonymous(), diff --git a/src/test/java/com/launchdarkly/sdk/LDContextTest.java b/src/test/java/com/launchdarkly/sdk/LDContextTest.java index 58ea409..f59f2bb 100644 --- a/src/test/java/com/launchdarkly/sdk/LDContextTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDContextTest.java @@ -402,6 +402,13 @@ public void contextFromUser() { .privateAttributes("c2") .build() )); + + // anonymous user with null key + LDUser u3 = new LDUser.Builder((String)null).anonymous(true).build(); + LDContext c3 = LDContext.fromUser(u3); + assertThat(c3.isValid(), is(true)); + assertThat(c3.getKey(), equalTo("")); + assertThat(c3.isAnonymous(), is(true)); } @Test From 4feece638bca6b6033af47664ace10268c997e64 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 1 Dec 2022 10:53:21 -0800 Subject: [PATCH 89/91] doc comment improvements for user/context types --- .../com/launchdarkly/sdk/ContextKind.java | 3 ++ .../java/com/launchdarkly/sdk/LDContext.java | 14 +++++++ .../java/com/launchdarkly/sdk/LDUser.java | 42 +++++++++++++++---- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/ContextKind.java b/src/main/java/com/launchdarkly/sdk/ContextKind.java index fa97623..7745839 100644 --- a/src/main/java/com/launchdarkly/sdk/ContextKind.java +++ b/src/main/java/com/launchdarkly/sdk/ContextKind.java @@ -25,6 +25,9 @@ * For a multi-kind context (see {@link LDContext#createMulti(LDContext...)}), the kind of * the top-level LDContext is always "multi" (the constant {@link #MULTI}); there is a * specific Kind for each of the contexts contained within it. + *

    + * To learn more, read the + * documentation. */ @JsonAdapter(ContextKindTypeAdapter.class) public final class ContextKind implements Comparable, JsonSerializable { diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java index 6d6bb98..23af19e 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContext.java +++ b/src/main/java/com/launchdarkly/sdk/LDContext.java @@ -29,6 +29,20 @@ *

    * An LDContext can be in an error state if it was built with invalid attributes. See * {@link #isValid()} and {@link #getError()}. + *

    + * LaunchDarkly defines a standard JSON encoding for contexts, used by the JavaScript SDK + * and also in analytics events. {@link LDContext} can be converted to and from JSON in any of + * these ways: + *

      + *
    1. With {@link 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}. + *
    + *

    + * To learn more about contexts, read the + * documentation. */ @JsonAdapter(LDContextTypeAdapter.class) public final class LDContext implements JsonSerializable { diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index 3107013..c6dbaa5 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -16,18 +16,42 @@ import static java.util.Collections.unmodifiableSet; /** - * A collection of attributes that can affect flag evaluation, usually corresponding to a user of your application. + * Attributes of a user for whom you are evaluating feature flags. *

    - * The only mandatory property is the {@code key}, which must uniquely identify each user; this could be a username - * or email address for authenticated users, or a session ID for anonymous users. All other built-in properties are - * optional. You may also define custom properties with arbitrary names and values. + * {@link LDUser} contains any user-specific properties that may be used in feature flag + * configurations to produce different flag variations for different users. You may define + * these properties however you wish. *

    - * For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference - * guides on Setting user attributes - * and Targeting users. + * LDUser supports only a subset of the behaviors that are available with the newer + * {@link LDContext} type. An LDUser is equivalent to an individual {@link LDContext} that has + * a {@link ContextKind} of {@link ContextKind#DEFAULT} ("user"); it also has more constraints + * on attribute values than LDContext does (for instance, built-in attributes such as + * {@link LDUser.Builder#email(String)} can only have string values). Older LaunchDarkly SDKs + * only had the LDUser model, and the LDUser type has been retained for backward compatibility, + * but it may be removed in a future SDK version; also, the SDK will always convert an LDUser + * to an LDContext internally, which has some overhead. Therefore, developers are recommended + * to migrate toward using LDContext. *

    - * LaunchDarkly defines a standard JSON encoding for user objects, used by the JavaScript SDK and also in analytics - * events. {@link LDUser} can be converted to and from JSON in any of these ways: + * The only mandatory property of LDUser 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 key, LDUser supports two kinds of optional attributes: built-in + * attributes (e.g. {@link LDUser.Builder#name(String)} and {@link LDUser.Builder#country(String)}) + * and custom attributes. The built-in attributes have specific allowed value types; also, two + * of them ({@code name} and {@code anonymous}) have special meanings in LaunchDarkly. Custom + * attributes have flexible value types, and can have any names that do not conflict with + * built-in attributes. + *

    + * Both built-in attributes and custom attributes can be referenced in targeting rules, and + * are included in analytics data. + *

    + * Instances of LDUser are immutable once created. They can be created with the constructor, + * or using a builder pattern with {@link LDUser.Builder}. + *

    + * LaunchDarkly defines a standard JSON encoding for user objects, used by the JavaScript SDK + * and also in analytics events. {@link LDUser} can be converted to and from JSON in any of + * these ways: *

      *
    1. With {@link JsonSerialization}. *
    2. With Gson, if and only if you configure your {@code Gson} instance with From f34e8f130adaddf601c8f55d7db27af5f6129dff Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 3 Jan 2023 09:47:36 -0800 Subject: [PATCH 90/91] update Gradle to 7.6 + fix snapshot releases (#59) * update Gradle to 7.3.3 + fix snapshot releases * actually it's 7.6 * update checkstyle version --- .circleci/config.yml | 6 + .ldrelease/config.yml | 2 +- .ldrelease/publish.sh | 7 +- build.gradle.kts | 1 + buildSrc/src/main/kotlin/helpers/Jacoco.kt | 6 +- checkstyle.xml | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 59536 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 257 ++++++++++++--------- 9 files changed, 172 insertions(+), 111 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a54648d..f995698 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -19,11 +19,17 @@ workflows: requires: - build-linux - test-linux: + # current LTS version name: Java 17 - Linux - OpenJDK docker-image: cimg/openjdk:17.0 with-coverage: true requires: - build-linux + - test-linux: + name: Java 19 - Linux - OpenJDK + docker-image: cimg/openjdk:19.0 + requires: + - build-linux # Windows Java 11 build is temporarily disabled - see story 171428 # - test-windows: # name: Java 11 - Windows - OpenJDK diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index b3cd8bb..c9f3723 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -12,7 +12,7 @@ publications: jobs: - docker: - image: gradle:6.8.3-jdk11 + image: gradle:7.6-jdk11 template: name: gradle diff --git a/.ldrelease/publish.sh b/.ldrelease/publish.sh index a2e9637..677f638 100755 --- a/.ldrelease/publish.sh +++ b/.ldrelease/publish.sh @@ -4,4 +4,9 @@ set -ue # Publish to Sonatype echo "Publishing to Sonatype" -./gradlew publishToSonatype closeAndReleaseRepository || { echo "Gradle publish/release failed" >&2; exit 1; } +if [[ -n "${LD_RELEASE_IS_PRERELEASE}" ]]; then + ./gradlew publishToSonatype || { echo "Gradle publish/release failed" >&2; exit 1; } +else + ./gradlew publishToSonatype closeAndReleaseRepository || { echo "Gradle publish/release failed" >&2; exit 1; } +fi + diff --git a/build.gradle.kts b/build.gradle.kts index 5f6dbb6..f74f2fd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { // see Dependencies.kt in buildSrc } checkstyle { + toolVersion = "9.3" configFile = file("${project.rootDir}/checkstyle.xml") } diff --git a/buildSrc/src/main/kotlin/helpers/Jacoco.kt b/buildSrc/src/main/kotlin/helpers/Jacoco.kt index 77f6c87..aa8267a 100644 --- a/buildSrc/src/main/kotlin/helpers/Jacoco.kt +++ b/buildSrc/src/main/kotlin/helpers/Jacoco.kt @@ -12,9 +12,9 @@ object Jacoco { verificationTask: TaskProvider) { reportTask.configure { reports { - xml.isEnabled = true - csv.isEnabled = true - html.isEnabled = true + xml.required.set(false) + csv.required.set(false) + html.required.set(true) } } diff --git a/checkstyle.xml b/checkstyle.xml index 0b201f9..7800907 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -6,7 +6,7 @@ - + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..7454180f2ae8848c63b8b4dea2cb829da983f2fa 100644 GIT binary patch delta 18435 zcmY&<19zBR)MXm8v2EM7ZQHi-#I|kQZfv7Tn#Q)%81v4zX3d)U4d4 zYYc!v@NU%|U;_sM`2z(4BAilWijmR>4U^KdN)D8%@2KLcqkTDW%^3U(Wg>{qkAF z&RcYr;D1I5aD(N-PnqoEeBN~JyXiT(+@b`4Pv`;KmkBXYN48@0;iXuq6!ytn`vGp$ z6X4DQHMx^WlOek^bde&~cvEO@K$oJ}i`T`N;M|lX0mhmEH zuRpo!rS~#&rg}ajBdma$$}+vEhz?JAFUW|iZEcL%amAg_pzqul-B7Itq6Y_BGmOCC zX*Bw3rFz3R)DXpCVBkI!SoOHtYstv*e-May|+?b80ZRh$MZ$FerlC`)ZKt} zTd0Arf9N2dimjs>mg5&@sfTPsRXKXI;0L~&t+GH zkB<>wxI9D+k5VHHcB7Rku{Z>i3$&hgd9Mt_hS_GaGg0#2EHzyV=j=u5xSyV~F0*qs zW{k9}lFZ?H%@4hII_!bzao!S(J^^ZZVmG_;^qXkpJb7OyR*sPL>))Jx{K4xtO2xTr@St!@CJ=y3q2wY5F`77Tqwz8!&Q{f7Dp zifvzVV1!Dj*dxG%BsQyRP6${X+Tc$+XOG zzvq5xcC#&-iXlp$)L=9t{oD~bT~v^ZxQG;FRz|HcZj|^L#_(VNG)k{=_6|6Bs-tRNCn-XuaZ^*^hpZ@qwi`m|BxcF6IWc?_bhtK_cDZRTw#*bZ2`1@1HcB`mLUmo_>@2R&nj7&CiH zF&laHkG~7#U>c}rn#H)q^|sk+lc!?6wg0xy`VPn!{4P=u@cs%-V{VisOxVqAR{XX+ zw}R;{Ux@6A_QPka=48|tph^^ZFjSHS1BV3xfrbY84^=?&gX=bmz(7C({=*oy|BEp+ zYgj;<`j)GzINJA>{HeSHC)bvp6ucoE`c+6#2KzY9)TClmtEB1^^Mk)(mXWYvup02e%Ghm9qyjz#fO3bNGBX} zFiB>dvc1+If!>I10;qZk`?6pEd*(?bI&G*3YLt;MWw&!?=Mf7%^Op?qnyXWur- zwX|S^P>jF?{m9c&mmK-epCRg#WB+-VDe!2d2~YVoi%7_q(dyC{(}zB${!ElKB2D}P z7QNFM!*O^?FrPMGZ}wQ0TrQAVqZy!weLhu_Zq&`rlD39r*9&2sJHE(JT0EY5<}~x@ z1>P0!L2IFDqAB!($H9s2fI`&J_c+5QT|b#%99HA3@zUWOuYh(~7q7!Pf_U3u!ij5R zjFzeZta^~RvAmd_TY+RU@e}wQaB_PNZI26zmtzT4iGJg9U(Wrgrl>J%Z3MKHOWV(? zj>~Ph$<~8Q_sI+)$DOP^9FE6WhO09EZJ?1W|KidtEjzBX3RCLUwmj9qH1CM=^}MaK z59kGxRRfH(n|0*lkE?`Rpn6d^u5J6wPfi0WF(rucTv(I;`aW)3;nY=J=igkjsn?ED ztH&ji>}TW8)o!Jg@9Z}=i2-;o4#xUksQHu}XT~yRny|kg-$Pqeq!^78xAz2mYP9+4 z9gwAoti2ICvUWxE&RZ~}E)#M8*zy1iwz zHqN%q;u+f6Ti|SzILm0s-)=4)>eb5o-0K zbMW8ecB4p^6OuIX@u`f{>Yn~m9PINEl#+t*jqalwxIx=TeGB9(b6jA}9VOHnE$9sC zH`;epyH!k-3kNk2XWXW!K`L_G!%xOqk0ljPCMjK&VweAxEaZ==cT#;!7)X&C|X{dY^IY(e4D#!tx^vV3NZqK~--JW~wtXJ8X19adXim?PdN(|@o(OdgH3AiHts~?#QkolO?*=U_buYC&tQ3sc(O5HGHN~=6wB@dgIAVT$ z_OJWJ^&*40Pw&%y^t8-Wn4@l9gOl`uU z{Uda_uk9!Iix?KBu9CYwW9Rs=yt_lE11A+k$+)pkY5pXpocxIEJe|pTxwFgB%Kpr&tH;PzgOQ&m|(#Otm?@H^r`v)9yiR8v&Uy>d#TNdRfyN4Jk;`g zp+jr5@L2A7TS4=G-#O<`A9o;{En5!I8lVUG?!PMsv~{E_yP%QqqTxxG%8%KxZ{uwS zOT+EA5`*moN8wwV`Z=wp<3?~f#frmID^K?t7YL`G^(X43gWbo!6(q*u%HxWh$$^2EOq`Hj zp=-fS#Av+s9r-M)wGIggQ)b<@-BR`R8l1G@2+KODmn<_$Tzb7k35?e8;!V0G>`(!~ zY~qZz!6*&|TupOcnvsQYPbcMiJ!J{RyfezB^;fceBk znpA1XS)~KcC%0^_;ihibczSxwBuy;^ksH7lwfq7*GU;TLt*WmUEVQxt{ zKSfJf;lk$0XO8~48Xn2dnh8tMC9WHu`%DZj&a`2!tNB`5%;Md zBs|#T0Ktf?vkWQ)Y+q!At1qgL`C|nbzvgc(+28Q|4N6Geq)Il%+I5c@t02{9^=QJ?=h2BTe`~BEu=_u3xX2&?^zwcQWL+)7dI>JK0g8_`W1n~ zMaEP97X>Ok#=G*nkPmY`VoP8_{~+Rp7DtdSyWxI~?TZHxJ&=6KffcO2Qx1?j7=LZA z?GQt`oD9QpXw+s7`t+eeLO$cpQpl9(6h3_l9a6OUpbwBasCeCw^UB6we!&h9Ik@1zvJ`j4i=tvG9X8o34+N|y(ay~ho$f=l z514~mP>Z>#6+UxM<6@4z*|hFJ?KnkQBs_9{H(-v!_#Vm6Z4(xV5WgWMd3mB9A(>@XE292#k(HdI7P zJkQ2)`bQXTKlr}{VrhSF5rK9TsjtGs0Rs&nUMcH@$ZX_`Hh$Uje*)(Wd&oLW($hZQ z_tPt`{O@f8hZ<}?aQc6~|9iHt>=!%We3=F9yIfiqhXqp=QUVa!@UY@IF5^dr5H8$R zIh{=%S{$BHG+>~a=vQ={!B9B=<-ID=nyjfA0V8->gN{jRL>Qc4Rc<86;~aY+R!~Vs zV7MI~gVzGIY`B*Tt@rZk#Lg}H8sL39OE31wr_Bm%mn}8n773R&N)8B;l+-eOD@N$l zh&~Wz`m1qavVdxwtZLACS(U{rAa0;}KzPq9r76xL?c{&GaG5hX_NK!?)iq`t7q*F# zFoKI{h{*8lb>&sOeHXoAiqm*vV6?C~5U%tXR8^XQ9Y|(XQvcz*>a?%HQ(Vy<2UhNf zVmGeOO#v159KV@1g`m%gJ)XGPLa`a|?9HSzSSX{j;)xg>G(Ncc7+C>AyAWYa(k}5B3mtzg4tsA=C^Wfezb1&LlyrBE1~kNfeiubLls{C)!<%#m@f}v^o+7<VZ6!FZ;JeiAG@5vw7Li{flC8q1%jD_WP2ApBI{fQ}kN zhvhmdZ0bb5(qK@VS5-)G+@GK(tuF6eJuuV5>)Odgmt?i_`tB69DWpC~e8gqh!>jr_ zL1~L0xw@CbMSTmQflpRyjif*Y*O-IVQ_OFhUw-zhPrXXW>6X}+73IoMsu2?uuK3lT>;W#38#qG5tDl66A7Y{mYh=jK8Se!+f=N7%nv zYSHr6a~Nxd`jqov9VgII{%EpC_jFCEc>>SND0;}*Ja8Kv;G)MK7?T~h((c&FEBcQq zvUU1hW2^TX(dDCeU@~a1LF-(+#lz3997A@pipD53&Dr@III2tlw>=!iGabjXzbyUJ z4Hi~M1KCT-5!NR#I%!2Q*A>mqI{dpmUa_mW)%SDs{Iw1LG}0y=wbj@0ba-`q=0!`5 zr(9q1p{#;Rv2CY!L#uTbs(UHVR5+hB@m*zEf4jNu3(Kj$WwW|v?YL*F_0x)GtQC~! zzrnZRmBmwt+i@uXnk05>uR5&1Ddsx1*WwMrIbPD3yU*2By`71pk@gt{|H0D<#B7&8 z2dVmXp*;B)SWY)U1VSNs4ds!yBAj;P=xtatUx^7_gC5tHsF#vvdV;NmKwmNa1GNWZ zi_Jn-B4GnJ%xcYWD5h$*z^haku#_Irh818x^KB)3-;ufjf)D0TE#6>|zFf@~pU;Rs zNw+}c9S+6aPzxkEA6R%s*xhJ37wmgc)-{Zd1&mD5QT}4BQvczWr-Xim>(P^)52`@R z9+Z}44203T5}`AM_G^Snp<_KKc!OrA(5h7{MT^$ZeDsSr(R@^kI?O;}QF)OU zQ9-`t^ys=6DzgLcWt0U{Q(FBs22=r zKD%fLQ^5ZF24c-Z)J{xv?x$&4VhO^mswyb4QTIofCvzq+27*WlYm;h@;Bq%i;{hZA zM97mHI6pP}XFo|^pRTuWQzQs3B-8kY@ajLV!Fb?OYAO3jFv*W-_;AXd;G!CbpZt04iW`Ie^_+cQZGY_Zd@P<*J9EdRsc>c=edf$K|;voXRJ zk*aC@@=MKwR120(%I_HX`3pJ+8GMeO>%30t?~uXT0O-Tu-S{JA;zHoSyXs?Z;fy58 zi>sFtI7hoxNAdOt#3#AWFDW)4EPr4kDYq^`s%JkuO7^efX+u#-qZ56aoRM!tC^P6O zP(cFuBnQGjhX(^LJ(^rVe4-_Vk*3PkBCj!?SsULdmVr0cGJM^=?8b0^DuOFq>0*yA zk1g|C7n%pMS0A8@Aintd$fvRbH?SNdRaFrfoAJ=NoX)G5Gr}3-$^IGF+eI&t{I-GT zp=1fj)2|*ur1Td)+s&w%p#E6tDXX3YYOC{HGHLiCvv?!%%3DO$B$>A}aC;8D0Ef#b z{7NNqC8j+%1n95zq8|hFY`afAB4E)w_&7?oqG0IPJZv)lr{MT}>9p?}Y`=n+^CZ6E zKkjIXPub5!82(B-O2xQojW^P(#Q*;ETpEr^+Wa=qDJ9_k=Wm@fZB6?b(u?LUzX(}+ zE6OyapdG$HC& z&;oa*ALoyIxVvB2cm_N&h&{3ZTuU|aBrJlGOLtZc3KDx)<{ z27@)~GtQF@%6B@w3emrGe?Cv_{iC@a#YO8~OyGRIvp@%RRKC?fclXMP*6GzBFO z5U4QK?~>AR>?KF@I;|(rx(rKxdT9-k-anYS+#S#e1SzKPslK!Z&r8iomPsWG#>`Ld zJ<#+8GFHE!^wsXt(s=CGfVz5K+FHYP5T0E*?0A-z*lNBf)${Y`>Gwc@?j5{Q|6;Bl zkHG1%r$r&O!N^><8AEL+=y(P$7E6hd=>BZ4ZZ9ukJ2*~HR4KGvUR~MUOe$d>E5UK3 z*~O2LK4AnED}4t1Fs$JgvPa*O+WeCji_cn1@Tv7XQ6l@($F1K%{E$!naeX)`bfCG> z8iD<%_M6aeD?a-(Qqu61&fzQqC(E8ksa%CulMnPvR35d{<`VsmaHyzF+B zF6a@1$CT0xGVjofcct4SyxA40uQ`b#9kI)& z?B67-12X-$v#Im4CVUGZHXvPWwuspJ610ITG*A4xMoRVXJl5xbk;OL(;}=+$9?H`b z>u2~yd~gFZ*V}-Q0K6E@p}mtsri&%Zep?ZrPJmv`Qo1>94Lo||Yl)nqwHXEbe)!g( zo`w|LU@H14VvmBjjkl~=(?b{w^G$~q_G(HL`>|aQR%}A64mv0xGHa`S8!*Wb*eB}` zZh)&rkjLK!Rqar)UH)fM<&h&@v*YyOr!Xk2OOMV%$S2mCRdJxKO1RL7xP_Assw)bb z9$sQ30bapFfYTS`i1PihJZYA#0AWNmp>x(;C!?}kZG7Aq?zp!B+gGyJ^FrXQ0E<>2 zCjqZ(wDs-$#pVYP3NGA=en<@_uz!FjFvn1&w1_Igvqs_sL>ExMbcGx4X5f%`Wrri@ z{&vDs)V!rd=pS?G(ricfwPSg(w<8P_6=Qj`qBC7_XNE}1_5>+GBjpURPmvTNE7)~r)Y>ZZecMS7Ro2` z0}nC_GYo3O7j|Wux?6-LFZs%1IV0H`f`l9or-8y0=5VGzjPqO2cd$RRHJIY06Cnh- ztg@Pn1OeY=W`1Mv3`Ti6!@QIT{qcC*&vptnX4Pt1O|dWv8u2s|(CkV`)vBjAC_U5` zCw1f&c4o;LbBSp0=*q z3Y^horBAnR)u=3t?!}e}14%K>^562K!)Vy6r~v({5{t#iRh8WIL|U9H6H97qX09xp zjb0IJ^9Lqxop<-P*VA0By@In*5dq8Pr3bTPu|ArID*4tWM7w+mjit0PgmwLV4&2PW z3MnIzbdR`3tPqtUICEuAH^MR$K_u8~-U2=N1)R=l>zhygus44>6V^6nJFbW-`^)f} zI&h$FK)Mo*x?2`0npTD~jRd}5G~-h8=wL#Y-G+a^C?d>OzsVl7BFAaM==(H zR;ARWa^C3J)`p~_&FRsxt|@e+M&!84`eq)@aO9yBj8iifJv0xVW4F&N-(#E=k`AwJ z3EFXWcpsRlB%l_0Vdu`0G(11F7( zsl~*@XP{jS@?M#ec~%Pr~h z2`M*lIQaolzWN&;hkR2*<=!ORL(>YUMxOzj(60rQfr#wTrkLO!t{h~qg% zv$R}0IqVIg1v|YRu9w7RN&Uh7z$ijV=3U_M(sa`ZF=SIg$uY|=NdC-@%HtkUSEqJv zg|c}mKTCM=Z8YmsFQu7k{VrXtL^!Cts-eb@*v0B3M#3A7JE*)MeW1cfFqz~^S6OXFOIP&iL;Vpy z4dWKsw_1Wn%Y;eW1YOfeP_r1s4*p1C(iDG_hrr~-I%kA>ErxnMWRYu{IcG{sAW;*t z9T|i4bI*g)FXPpKM@~!@a7LDVVGqF}C@mePD$ai|I>73B+9!Ks7W$pw;$W1B%-rb; zJ*-q&ljb=&41dJ^*A0)7>Wa@khGZ;q1fL(2qW=|38j43mTl_;`PEEw07VKY%71l6p z@F|jp88XEnm1p~<5c*cVXvKlj0{THF=n3sU7g>Ki&(ErR;!KSmfH=?49R5(|c_*xw z4$jhCJ1gWT6-g5EV)Ahg?Nw=}`iCyQ6@0DqUb%AZEM^C#?B-@Hmw?LhJ^^VU>&phJ zlB!n5&>I>@sndh~v$2I2Ue23F?0!0}+9H~jg7E`?CS_ERu75^jSwm%!FTAegT`6s7 z^$|%sj2?8wtPQR>@D3sA0-M-g-vL@47YCnxdvd|1mPymvk!j5W1jHnVB&F-0R5e-vs`@u8a5GKdv`LF7uCfKncI4+??Z4iG@AxuX7 z6+@nP^TZ5HX#*z(!y+-KJ3+Ku0M90BTY{SC^{ z&y2#RZPjfX_PE<<>XwGp;g4&wcXsQ0T&XTi(^f+}4qSFH1%^GYi+!rJo~t#ChTeAX zmR0w(iODzQOL+b&{1OqTh*psAb;wT*drr^LKdN?c?HJ*gJl+%kEH&48&S{s28P=%p z7*?(xFW_RYxJxxILS!kdLIJYu@p#mnQ(?moGD1)AxQd66X6b*KN?o&e`u9#N4wu8% z^Gw#G!@|>c740RXziOR=tdbkqf(v~wS_N^CS^1hN-N4{Dww1lvSWcBTX*&9}Cz|s@ z*{O@jZ4RVHq19(HC9xSBZI0M)E;daza+Q*zayrX~N5H4xJ33BD4gn5Ka^Hj{995z4 zzm#Eo?ntC$q1a?)dD$qaC_M{NW!5R!vVZ(XQqS67xR3KP?rA1^+s3M$60WRTVHeTH z6BJO$_jVx0EGPXy}XK_&x597 zt(o6ArN8vZX0?~(lFGHRtHP{gO0y^$iU6Xt2e&v&ugLxfsl;GD)nf~3R^ACqSFLQ< zV7`cXgry((wDMJB55a6D4J;13$z6pupC{-F+wpToW%k1qKjUS^$Mo zN3@}T!ZdpiV7rkNvqP3KbpEn|9aB;@V;gMS1iSb@ zwyD7!5mfj)q+4jE1dq3H`sEKgrVqk|y8{_vmn8bMOi873!rmnu5S=1=-DFx+Oj)Hi zx?~ToiJqOrvSou?RVALltvMADodC7BOg7pOyc4m&6yd(qIuV5?dYUpYzpTe!BuWKi zpTg(JHBYzO&X1e{5o|ZVU-X5e?<}mh=|eMY{ldm>V3NsOGwyxO2h)l#)rH@BI*TN; z`yW26bMSp=k6C4Ja{xB}s`dNp zE+41IwEwo>7*PA|7v-F#jLN>h#a`Er9_86!fwPl{6yWR|fh?c%qc44uP~Ocm2V*(* zICMpS*&aJjxutxKC0Tm8+FBz;3;R^=ajXQUB*nTN*Lb;mruQHUE<&=I7pZ@F-O*VMkJbI#FOrBM8`QEL5Uy=q5e2 z_BwVH%c0^uIWO0*_qD;0jlPoA@sI7BPwOr-mrp7y`|EF)j;$GYdOtEPFRAKyUuUZS z(N4)*6R*ux8s@pMdC*TP?Hx`Zh{{Ser;clg&}CXriXZCr2A!wIoh;j=_eq3_%n7V} za?{KhXg2cXPpKHc90t6=`>s@QF-DNcTJRvLTS)E2FTb+og(wTV7?$kI?QZYgVBn)& zdpJf@tZ{j>B;<MVHiPl_U&KlqBT)$ic+M0uUQWK|N1 zCMl~@o|}!!7yyT%7p#G4?T^Azxt=D(KP{tyx^lD_(q&|zNFgO%!i%7T`>mUuU^FeR zHP&uClWgXm6iXgI8*DEA!O&X#X(zdrNctF{T#pyax16EZ5Lt5Z=RtAja!x+0Z31U8 zjfaky?W)wzd+66$L>o`n;DISQNs09g{GAv%8q2k>2n8q)O^M}=5r#^WR^=se#WSCt zQ`7E1w4qdChz4r@v6hgR?nsaE7pg2B6~+i5 zcTTbBQ2ghUbC-PV(@xvIR(a>Kh?{%YAsMV#4gt1nxBF?$FZ2~nFLKMS!aK=(`WllA zHS<_7ugqKw!#0aUtQwd#A$8|kPN3Af?Tkn)dHF?_?r#X68Wj;|$aw)Wj2Dkw{6)*^ zZfy!TWwh=%g~ECDCy1s8tTgWCi}F1BvTJ9p3H6IFq&zn#3FjZoecA_L_bxGWgeQup zAAs~1IPCnI@H>g|6Lp^Bk)mjrA3_qD4(D(65}l=2RzF-8@h>|Aq!2K-qxt(Q9w7c^ z;gtx`I+=gKOl;h=#fzSgw-V*YT~2_nnSz|!9hIxFb{~dKB!{H zSi??dnmr@%(1w^Be=*Jz5bZeofEKKN&@@uHUMFr-DHS!pb1I&;x9*${bmg6=2I4Zt zHb5LSvojY7ubCNGhp)=95jQ00sMAC{IZdAFsN!lAVQDeiec^HAu=8);2AKqNTT!&E zo+FAR`!A1#T6w@0A+o%&*yzkvxsrqbrfVTG+@z8l4+mRi@j<&)U9n6L>uZoezW>qS zA4YfO;_9dQSyEYpkWnsk0IY}Nr2m(ql@KuQjLgY-@g z4=$uai6^)A5+~^TvLdvhgfd+y?@+tRE^AJabamheJFnpA#O*5_B%s=t8<;?I;qJ}j z&g-9?hbwWEez-!GIhqpB>nFvyi{>Yv>dPU=)qXnr;3v-cd`l}BV?6!v{|cHDOx@IG z;TSiQQ(8=vlH^rCEaZ@Yw}?4#a_Qvx=}BJuxACxm(E7tP4hki^jU@8A zUS|4tTLd)gr@T|F$1eQXPY%fXb7u}(>&9gsd3It^B{W#6F2_g40cgo1^)@-xO&R5X z>qKon+Nvp!4v?-rGQu#M_J2v+3e+?N-WbgPQWf`ZL{Xd9KO^s{uIHTJ6~@d=mc7i z+##ya1p+ZHELmi%3C>g5V#yZt*jMv( zc{m*Y;7v*sjVZ-3mBuaT{$g+^sbs8Rp7BU%Ypi+c%JxtC4O}|9pkF-p-}F{Z7-+45 zDaJQx&CNR)8x~0Yf&M|-1rw%KW3ScjWmKH%J1fBxUp(;F%E+w!U470e_3%+U_q7~P zJm9VSWmZ->K`NfswW(|~fGdMQ!K2z%k-XS?Bh`zrjZDyBMu74Fb4q^A=j6+Vg@{Wc zPRd5Vy*-RS4p1OE-&8f^Fo}^yDj$rb+^>``iDy%t)^pHSV=En5B5~*|32#VkH6S%9 zxgIbsG+|{-$v7mhOww#v-ejaS>u(9KV9_*X!AY#N*LXIxor9hDv%aie@+??X6@Et=xz>6ev9U>6Pn$g4^!}w2Z%Kpqpp+M%mk~?GE-jL&0xLC zy(`*|&gm#mLeoRU8IU?Ujsv=;ab*URmsCl+r?%xcS1BVF*rP}XRR%MO_C!a9J^fOe>U;Y&3aj3 zX`3?i12*^W_|D@VEYR;h&b^s#Kd;JMNbZ#*x8*ZXm(jgw3!jyeHo14Zq!@_Q`V;Dv zKik~!-&%xx`F|l^z2A92aCt4x*I|_oMH9oeqsQgQDgI0j2p!W@BOtCTK8Jp#txi}7 z9kz);EX-2~XmxF5kyAa@n_$YYP^Hd4UPQ>O0-U^-pw1*n{*kdX`Jhz6{!W=V8a$0S z9mYboj#o)!d$gs6vf8I$OVOdZu7L5%)Vo0NhN`SwrQFhP3y4iXe2uV@(G{N{yjNG( zKvcN{k@pXkxyB~9ucR(uPSZ7{~sC=lQtz&V(^A^HppuN!@B4 zS>B=kb14>M-sR>{`teApuHlca6YXs6&sRvRV;9G!XI08CHS~M$=%T~g5Xt~$exVk` zWP^*0h{W%`>K{BktGr@+?ZP}2t0&smjKEVw@3=!rSjw5$gzlx`{dEajg$A58m|Okx zG8@BTPODSk@iqLbS*6>FdVqk}KKHuAHb0UJNnPm!(XO{zg--&@#!niF4T!dGVdNif z3_&r^3+rfQuV^8}2U?bkI5Ng*;&G>(O4&M<86GNxZK{IgKNbRfpg>+32I>(h`T&uv zUN{PRP&onFj$tn1+Yh|0AF330en{b~R+#i9^QIbl9fBv>pN|k&IL2W~j7xbkPyTL^ z*TFONZUS2f33w3)fdzr?)Yg;(s|||=aWZV(nkDaACGSxNCF>XLJSZ=W@?$*` z#sUftY&KqTV+l@2AP5$P-k^N`Bme-xcWPS|5O~arUq~%(z8z87JFB|llS&h>a>Som zC34(_uDViE!H2jI3<@d+F)LYhY)hoW6)i=9u~lM*WH?hI(yA$X#ip}yYld3RAv#1+sBt<)V_9c4(SN9Fn#$}_F}A-}P>N+8io}I3mh!}> z*~*N}ZF4Zergb;`R_g49>ZtTCaEsCHiFb(V{9c@X0`YV2O^@c6~LXg2AE zhA=a~!ALnP6aO9XOC^X15(1T)3!1lNXBEVj5s*G|Wm4YBPV`EOhU&)tTI9-KoLI-U zFI@adu6{w$dvT(zu*#aW*4F=i=!7`P!?hZy(9iL;Z^De3?AW`-gYTPALhrZ*K2|3_ zfz;6xQN9?|;#_U=4t^uS2VkQ8$|?Ub5CgKOj#Ni5j|(zX>x#K(h7LgDP-QHwok~-I zOu9rn%y97qrtKdG=ep)4MKF=TY9^n6CugQ3#G2yx;{))hvlxZGE~rzZ$qEHy-8?pU#G;bwufgSN6?*BeA!7N3RZEh{xS>>-G1!C(e1^ zzd#;39~PE_wFX3Tv;zo>5cc=md{Q}(Rb?37{;YPtAUGZo7j*yHfGH|TOVR#4ACaM2 z;1R0hO(Gl}+0gm9Bo}e@lW)J2OU4nukOTVKshHy7u)tLH^9@QI-jAnDBp(|J8&{fKu=_97$v&F67Z zq+QsJ=gUx3_h_%=+q47msQ*Ub=gMzoSa@S2>`Y9Cj*@Op4plTc!jDhu51nSGI z^sfZ(4=yzlR}kP2rcHRzAY9@T7f`z>fdCU0zibx^gVg&fMkcl)-0bRyWe12bT0}<@ z^h(RgGqS|1y#M;mER;8!CVmX!j=rfNa6>#_^j{^C+SxGhbSJ_a0O|ae!ZxiQCN2qA zKs_Z#Zy|9BOw6x{0*APNm$6tYVG2F$K~JNZ!6>}gJ_NLRYhcIsxY1z~)mt#Yl0pvC zO8#Nod;iow5{B*rUn(0WnN_~~M4|guwfkT(xv;z)olmj=f=aH#Y|#f_*d1H!o( z!EXNxKxth9w1oRr0+1laQceWfgi8z`YS#uzg#s9-QlTT7y2O^^M1PZx z3YS7iegfp6Cs0-ixlG93(JW4wuE7)mfihw}G~Uue{Xb+#F!BkDWs#*cHX^%(We}3% zT%^;m&Juw{hLp^6eyM}J({luCL_$7iRFA6^8B!v|B9P{$42F>|M`4Z_yA{kK()WcM zu#xAZWG%QtiANfX?@+QQOtbU;Avr*_>Yu0C2>=u}zhH9VLp6M>fS&yp*-7}yo8ZWB z{h>ce@HgV?^HgwRThCYnHt{Py0MS=Ja{nIj5%z;0S@?nGQ`z`*EVs&WWNwbzlk`(t zxDSc)$dD+4G6N(p?K>iEKXIk>GlGKTH{08WvrehnHhh%tgpp&8db4*FLN zETA@<$V=I7S^_KxvYv$Em4S{gO>(J#(Wf;Y%(NeECoG3n+o;d~Bjme-4dldKukd`S zRVAnKxOGjWc;L#OL{*BDEA8T=zL8^`J=2N)d&E#?OMUqk&9j_`GX*A9?V-G zdA5QQ#(_Eb^+wDkDiZ6RXL`fck|rVy%)BVv;dvY#`msZ}{x5fmd! zInmWSxvRgXbJ{unxAi*7=Lt&7_e0B#8M5a=Ad0yX#0rvMacnKnXgh>4iiRq<&wit93n!&p zeq~-o37qf)L{KJo3!{l9l9AQb;&>)^-QO4RhG>j`rBlJ09~cbfNMR_~pJD1$UzcGp zOEGTzz01j$=-kLC+O$r8B|VzBotz}sj(rUGOa7PDYwX~9Tum^sW^xjjoncxSz;kqz z$Pz$Ze|sBCTjk7oM&`b5g2mFtuTx>xl{dj*U$L%y-xeQL~|i>KzdUHeep-Yd@}p&L*ig< zgg__3l9T=nbM3bw0Sq&Z2*FA)P~sx0h634BXz0AxV69cED7QGTbK3?P?MENkiy-mV zZ1xV5ry3zIpy>xmThBL0Q!g+Wz@#?6fYvzmEczs(rcujrfCN=^!iWQ6$EM zaCnRThqt~gI-&6v@KZ78unqgv9j6-%TOxpbV`tK{KaoBbhc}$h+rK)5h|bT6wY*t6st-4$e99+Egb#3ip+ERbve08G@Ref&hP)qB&?>B94?eq5i3k;dOuU#!y-@+&5>~!FZik=z4&4|YHy=~!F254 zQAOTZr26}Nc7jzgJ;V~+9ry#?7Z0o*;|Q)k+@a^87lC}}1C)S))f5tk+lMNqw>vh( z`A9E~5m#b9!ZDBltf7QIuMh+VheCoD7nCFhuzThlhA?|8NCt3w?oWW|NDin&&eDU6 zwH`aY=))lpWG?{fda=-auXYp1WIPu&3 zwK|t(Qiqvc@<;1_W#ALDJ}bR;3&v4$9rP)eAg`-~iCte`O^MY+SaP!w%~+{{1tMo` zbp?T%ENs|mHP)Lsxno=nWL&qizR+!Ib=9i%4=B@(Umf$|7!WVxkD%hfRjvxV`Co<; zG*g4QG_>;RE{3V_DOblu$GYm&!+}%>G*yO{-|V9GYG|bH2JIU2iO}ZvY>}Fl%1!OE zZFsirH^$G>BDIy`8;R?lZl|uu@qWj2T5}((RG``6*05AWsVVa2Iu>!F5U>~7_Tlv{ zt=Dpgm~0QVa5mxta+fUt)I0gToeEm9eJX{yYZ~3sLR&nCuyuFWuiDIVJ+-lwViO(E zH+@Rg$&GLueMR$*K8kOl>+aF84Hss5p+dZ8hbW$=bWNIk0paB!qEK$xIm5{*^ad&( zgtA&gb&6FwaaR2G&+L+Pp>t^LrG*-B&Hv;-s(h0QTuYWdnUObu8LRSZoAVd7SJ;%$ zh%V?58mD~3G2X<$H7I)@x?lmbeeSY7X~QiE`dfQ5&K^FB#9e!6!@d9vrSt!);@ZQZ zO#84N5yH$kjm9X4iY#f+U`FKhg=x*FiDoUeu1O5LcC2w&$~5hKB9ZnH+8BpbTGh5T zi_nfmyQY$vQh%ildbR7T;7TKPxSs#vhKR|uup`qi1PufMa(tNCjRbllakshQgn1)a8OO-j8W&aBc_#q1hKDF5-X$h`!CeT z+c#Ial~fDsGAenv7~f@!icm(~)a3OKi((=^zcOb^qH$#DVciGXslUwTd$gt{7)&#a`&Lp ze%AnL0#U?lAl8vUkv$n>bxH*`qOujO0HZkPWZnE0;}0DSEu1O!hg-d9#{&#B1Dm)L zvN%r^hdEt1vR<4zwshg*0_BNrDWjo65be1&_82SW8#iKWs7>TCjUT;-K~*NxpG2P% zovXUo@S|fMGudVSRQrP}J3-Wxq;4xIxJJC|Y#TQBr>pwfy*%=`EUNE*dr-Y?9y9xK zmh1zS@z{^|UL}v**LNYY!?1qIRPTvr!gNXzE{%=-`oKclPrfMKwn` zUwPeIvLcxkIV>(SZ-SeBo-yw~{p!<&_}eELG?wxp zee-V59%@BtB+Z&Xs=O(@P$}v_qy1m=+`!~r^aT> zY+l?+6(L-=P%m4ScfAYR8;f9dyVw)@(;v{|nO#lAPI1xDHXMYt~-BGiP&9y2OQsYdh7-Q1(vL<$u6W0nxVn-qh=nwuRk}{d!uACozccRGx6~xZQ;=#JCE?OuA@;4 zadp$sm}jfgW4?La(pb!3f0B=HUI{5A4b$2rsB|ZGb?3@CTA{|zBf07pYpQ$NM({C6Srv6%_{rVkCndT=1nS}qyEf}Wjtg$e{ng7Wgz$7itYy0sWW_$qld);iUm85GBH)fk3b=2|5mvflm?~inoVo zDH_%e;y`DzoNj|NgZ`U%a9(N*=~8!qqy0Etkxo#`r!!{|(NyT0;5= z8nVZ6AiM+SjMG8J@6c4_f-KXd_}{My?Se1GWP|@wROFpD^5_lu?I%CBzpwi(`x~xh B8dv}T delta 17845 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 X49Luk)baa85H9$5YCsTPT`SVRWMtMW diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2e6e589..070cb70 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0..1b6c787 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +140,95 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" From e1f5c240df88c5c5d652f7579482f77bb43e07cc Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Wed, 17 May 2023 14:29:08 -0500 Subject: [PATCH 91/91] Improving equality checks to remove NPE. --- .../java/com/launchdarkly/sdk/LDContext.java | 22 ++++++++++++------- .../com/launchdarkly/sdk/LDContextTest.java | 16 ++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/LDContext.java b/src/main/java/com/launchdarkly/sdk/LDContext.java index 23af19e..c5a4d76 100644 --- a/src/main/java/com/launchdarkly/sdk/LDContext.java +++ b/src/main/java/com/launchdarkly/sdk/LDContext.java @@ -135,7 +135,9 @@ static LDContext createMultiInternal(LDContext[] multiContexts) { errors.add(c.getError()); } else { for (int j = 0; j < i; j++) { - if (multiContexts[j].getKind().equals(c.getKind())) { + // since kind can be null in the malformed context case, need to do equality check with null safety. + // Objects.equals handles the null equality case without a NPE. + if (Objects.equals(multiContexts[j].getKind(), c.getKind())) { duplicates = true; break; } @@ -723,10 +725,12 @@ public LDContext getIndividualContext(ContextKind kind) { kind = ContextKind.DEFAULT; } if (multiContexts == null) { - return this.kind.equals(kind) ? this : null; + // kind.equals since kind has already been sanitized + return kind.equals(this.kind) ? this : null; } for (LDContext c: multiContexts) { - if (c.kind.equals(kind)) { + // kind.equals since kind has already been sanitized + if (kind.equals(c.kind)) { return c; } } @@ -745,10 +749,12 @@ public LDContext getIndividualContext(String kind) { return getIndividualContext(ContextKind.DEFAULT); } if (multiContexts == null) { - return this.kind.toString().equals(kind) ? this : null; + // kind.equals since kind has already been sanitized + return kind.equals(this.kind.toString()) ? this : null; } for (LDContext c: multiContexts) { - if (c.kind.toString().equals(kind)) { + // kind.equals since kind has already been sanitized + if (kind.equals(c.kind.toString())) { return c; } } @@ -827,7 +833,7 @@ public boolean equals(Object other) { if (error != null) { return true; // there aren't any other attributes } - if (!kind.equals(o.kind)) { + if (!Objects.equals(kind, o.kind)) { return false; } if (isMultiple()) { @@ -835,13 +841,13 @@ public boolean equals(Object other) { return false; } for (int i = 0; i < multiContexts.length; i++) { - if (!multiContexts[i].equals(o.multiContexts[i])) { + if (!Objects.equals(multiContexts[i], o.multiContexts[i])) { return false; } } return true; } - if (!key.equals(o.key) || !Objects.equals(name, o.name) || anonymous != o.anonymous) { + if (!Objects.equals(key, o.key) || !Objects.equals(name, o.name) || anonymous != o.anonymous) { return false; } if ((attributes == null ? 0 : attributes.size()) != diff --git a/src/test/java/com/launchdarkly/sdk/LDContextTest.java b/src/test/java/com/launchdarkly/sdk/LDContextTest.java index f59f2bb..de85237 100644 --- a/src/test/java/com/launchdarkly/sdk/LDContextTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDContextTest.java @@ -15,6 +15,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.sameInstance; import static org.junit.Assert.fail; @@ -301,6 +302,21 @@ public void multiKindContexts() { assertThat(LDContext.createMulti(c1plus2, c3), equalTo(LDContext.createMulti(c1, c2, c3))); } + + @Test + public void multiBuilderWithInvalidContextHasError() { + LDContext c1 = LDContext.create(ContextKind.of("#####"), "key1"); + LDContext c2 = LDContext.create(kind2, "key2"); + LDContext output = LDContext.createMulti(c1, c2); + assertThat(output, + equalTo(LDContext.multiBuilder().add(c1).add(c2).build())); + + // we expect an error from the invalid context to propagate up + assertThat(output.getError(), notNullValue()); + + // we expect getting individual contexts to also fail in the error case + assertThat(output.getIndividualContext(kind2), nullValue()); + } @Test public void stringRepresentation() {