diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..ba4c792 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,166 @@ +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 + with-coverage: true + requires: + - build-linux + - build-test-windows: + name: Java 11 - Windows - OpenJDK + - build-test-android: + name: Android + +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 + with-coverage: + type: boolean + default: false + docker: + - image: <> + steps: + - checkout + - run: cp gradle.properties.example gradle.properties + - attach_workspace: + 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: | + 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 + - when: + condition: <> + steps: + - store_artifacts: + path: build/reports/jacoco + + 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 + + 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/.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..ef2e196 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..02aa5f4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# 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. + +## 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 + +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 +``` + +## 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. + +## 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/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..e1187d5 --- /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 7 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-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 new file mode 100644 index 0000000..f4fca2a --- /dev/null +++ b/build.gradle @@ -0,0 +1,220 @@ + +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, + "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 + 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/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..d3ffc5d --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +version=1.0.0-rc1 +# The following empty ossrh properties are used by LaunchDarkly's internal integration testing framework +# and should not be needed for typical development purposes (including by third-party developers). +ossrhUsername= +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 0000000..5c2d1cf Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a2bf131 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.2.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..83f2acf --- /dev/null +++ b/gradlew @@ -0,0 +1,188 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# 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 +# +# https://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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# 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 +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/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/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/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/main/java/com/launchdarkly/sdk/ArrayBuilder.java b/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java new file mode 100644 index 0000000..0b763c5 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java @@ -0,0 +1,93 @@ +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. + */ +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..fcac4b1 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java @@ -0,0 +1,181 @@ +package com.launchdarkly.sdk; + +import com.google.gson.annotations.JsonAdapter; +import com.launchdarkly.sdk.json.JsonSerializable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * 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 any of these ways: + *

    + *
  1. With {@link com.launchdarkly.sdk.json.JsonSerialization}. + *
  2. With Gson, if and only if you configure your {@code Gson} instance with + * {@link com.launchdarkly.sdk.json.LDGson}. + *
  3. With Jackson, if and only if you configure your {@code ObjectMapper} instance with + * {@link com.launchdarkly.sdk.json.LDJackson}. + *
+ * + * 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 + * (evaluation failed and returned a default 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 >= 0 ? variationIndex : NO_VARIATION; + 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 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} + */ + @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); + } + + /** + * 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(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() { + return reason; + } + + /** + * 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 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() { + return value; + } + + /** + * Returns true if the flag evaluation returned the default value, rather than one of the flag's + * variations. If so, {@link #getVariationIndex()} will be {@link #NO_VARIATION}. + * + * @return true if this is the default value + */ + public boolean isDefaultValue() { + return variationIndex < 0; + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (other instanceof EvaluationDetail) { + @SuppressWarnings("unchecked") + EvaluationDetail o = (EvaluationDetail)other; + return Objects.equals(reason, o.reason) && variationIndex == o.variationIndex && Objects.equals(value, o.value); + } + return false; + } + + @Override + 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 "{" + 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/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/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java new file mode 100644 index 0000000..17e0084 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -0,0 +1,316 @@ +package com.launchdarkly.sdk; + +import com.google.gson.annotations.JsonAdapter; +import com.launchdarkly.sdk.json.JsonSerializable; + +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 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. + *

+ * LaunchDarkly defines a standard JSON encoding for evaluation reasons, used in analytics events. + * {@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 {@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}. + *
+ */ +@JsonAdapter(EvaluationReasonTypeAdapter.class) +public final class EvaluationReason implements JsonSerializable { + /** + * Enumerated type defining the possible values of {@link EvaluationReason#getKind()}. + */ + 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 #getErrorKind()}. + */ + 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 #getException()}. + */ + EXCEPTION + } + + // 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. + * + * @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 ruleIndex; + } + + /** + * 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 ruleId; + } + + /** + * 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 prerequisiteKey; + } + + /** + * 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 errorKind; + } + + /** + * 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}. + *

+ * 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 instance + */ + public Exception getException() { + return exception; + } + + /** + * 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() { + 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(); + } + } + + @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)&& + 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); + } + + /** + * Returns an instance whose {@code kind} is {@link Kind#OFF}. + * + * @return a reason object + */ + public static EvaluationReason off() { + return OFF_INSTANCE; + } + + /** + * Returns an instance whose {@code kind} is {@link Kind#FALLTHROUGH}. + * + * @return a reason object + */ + public static EvaluationReason fallthrough() { + return FALLTHROUGH_INSTANCE; + } + + /** + * Returns an instance whose {@code kind} is {@link Kind#TARGET_MATCH}. + * + * @return a reason object + */ + public static EvaluationReason targetMatch() { + return TARGET_MATCH_INSTANCE; + } + + /** + * Returns an instance whose {@code kind} is {@link Kind#RULE_MATCH}. + * + * @param ruleIndex the rule index + * @param ruleId the rule identifier + * @return a reason object + */ + public static EvaluationReason ruleMatch(int ruleIndex, String ruleId) { + return new EvaluationReason(Kind.RULE_MATCH, ruleIndex, ruleId, null, null, null); + } + + /** + * Returns an instance whose {@code kind} is {@link Kind#PREREQUISITE_FAILED}. + * + * @param prerequisiteKey the flag key of the prerequisite that failed + * @return a reason object + */ + public static EvaluationReason prerequisiteFailed(String prerequisiteKey) { + return new EvaluationReason(Kind.PREREQUISITE_FAILED, -1, null, prerequisiteKey, null, null); + } + + /** + * Returns an instance whose {@code kind} is {@link Kind#ERROR}. + * + * @param errorKind describes the type of error + * @return a reason object + */ + public static EvaluationReason 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 EvaluationReason(errorKind, null); // COVERAGE: compiler requires default but there are no other ErrorKind values + } + } + + /** + * 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 + */ + public static EvaluationReason exception(Exception 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..a505eb9 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java @@ -0,0 +1,106 @@ +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; + +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 { + return parse(reader); + } + + static EvaluationReason parse(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) { // 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)); + break; + case "ruleIndex": + ruleIndex = reader.nextInt(); + break; + case "ruleId": + ruleId = readNullableString(reader); + break; + case "prerequisiteKey": + prereqKey = reader.nextString(); + break; + case "errorKind": + 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(); + 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); + default: + // COVERAGE: compiler requires default but there are no other values + return null; + } + } + + @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(); + } +} 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 new file mode 100644 index 0000000..e0c85b4 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/Helpers.java @@ -0,0 +1,57 @@ +package com.launchdarkly.sdk; + +import com.google.gson.JsonParseException; +import com.google.gson.stream.JsonReader; + +import java.io.IOException; +import java.util.Iterator; + +/** + * 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 { + // 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()); + } + }; + } + }; + } + + // 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 new file mode 100644 index 0000000..11d7942 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -0,0 +1,684 @@ +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. + *

+ * 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. + *

+ * 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}. + *
+ */ +@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 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 ? 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(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 Objects.hash(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: + *

+   * 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); + } + + /** + * Changes the user's key. + * + * @param s the user key + * @return the builder + */ + public Builder key(String s) { + this.key = s; + return this; + } + + /** + * 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 + */ + 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 + */ + public Builder privateCustom(String k, LDValue 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 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/LDUserTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDUserTypeAdapter.java new file mode 100644 index 0000000..9528781 --- /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 "secondary": + builder.secondary(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 new file mode 100644 index 0000000..b358025 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -0,0 +1,663 @@ +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; +import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.json.SerializationException; + +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. + *

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

    + *
  1. With the {@link LDValue} methods {@link #toJsonString()} and {@link #parse(String)}. + *
  2. With {@link com.launchdarkly.sdk.json.JsonSerialization}. + *
  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(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. + * + * @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. + *

+ * 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} 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 + */ + public static LDValue parse(String json) { + try { + return JsonSerialization.deserialize(json, LDValue.class); + } catch (SerializationException e) { + throw new RuntimeException(e); + } + } + + /** + * 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); }
+   * 
+ *

+ * 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 + * @return an iterable of values of the specified type + */ + public Iterable valuesAs(final Converter converter) { + return transform(values(), new Function() { + @Override + public T apply(LDValue a) { + return converter.toType(a); + } + }); + } + + /** + * 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. + *

+ * This method is equivalent to passing the {@code LDValue} instance to + * {@link JsonSerialization#serialize(JsonSerializable)}. + * + * @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); + } + + /** + * 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) { + if (o == this) { + return true; + } + LDValue other = (LDValue)o; + if (getType() == other.getType()) { + switch (getType()) { + 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()) { + 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; + default: + break; + } + } + } + return false; + } + + @Override + public int hashCode() { + switch (getType()) { + 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 + */ + 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)}. + */ + 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..3c84fc5 --- /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.Collections; +import java.util.List; + +import static java.util.Collections.unmodifiableList; + +@JsonAdapter(LDValueTypeAdapter.class) +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); + } + + 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..8f72c84 --- /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 { + static final LDValueBool TRUE = new LDValueBool(true); + 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..00e8cda --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDValueObject.java @@ -0,0 +1,57 @@ +package com.launchdarkly.sdk; + +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +@JsonAdapter(LDValueTypeAdapter.class) +final class LDValueObject extends LDValue { + private static final LDValueObject EMPTY = new LDValueObject(Collections.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..9780c58 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDValueType.java @@ -0,0 +1,32 @@ +package com.launchdarkly.sdk; + +/** + * Describes the type of an {@link LDValue}. These correspond to the standard types in JSON. + */ +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..bd5f419 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java @@ -0,0 +1,55 @@ +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: + // 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: + return LDValue.of(reader.nextDouble()); + 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; + } + } + + @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..48c19fb --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java @@ -0,0 +1,104 @@ +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. + */ +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..6514851 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/UserAttribute.java @@ -0,0 +1,206 @@ +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 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. + */ + 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, 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 { + // 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/JsonSerializable.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerializable.java new file mode 100644 index 0000000..09ba91c --- /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 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 new file mode 100644 index 0000000..ee8b8de --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java @@ -0,0 +1,145 @@ +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.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +/** + * 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 LDGson}. + *
  3. For {@link LDValue}, you may also use the convenience methods {@link LDValue#toJsonString()} and + * {@link LDValue#parse(String)}. + *
+ */ +public abstract class JsonSerialization { + static final List> knownDeserializableClasses = new ArrayList<>(); + + 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. + *

+ * 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 + * @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); + } + } + + // 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. + *

+ * 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() { + // 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); + 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/LDGson.java b/src/main/java/com/launchdarkly/sdk/json/LDGson.java new file mode 100644 index 0000000..a5bc348 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/LDGson.java @@ -0,0 +1,125 @@ +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; +import java.lang.reflect.Type; + +/** + * 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: + *


+ *     import com.launchdarkly.sdk.json.LDGson;
+ *     
+ *     Gson gson = new GsonBuilder()
+ *       .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 + * {@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 LDGson} will cause a runtime + * exception if Gson is not in the caller's classpath. + */ +public abstract class LDGson { + + // Implementation note: + // 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 (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. + + /** + * 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 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.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; + this.objectType = objectType; + } + + @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 { + // 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 new file mode 100644 index 0000000..2c26680 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java @@ -0,0 +1,101 @@ +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. + *

+ * 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 { + /** + * 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 { + // Jackson will not call this serializer for a null value + 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/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..d87f652 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/package-info.java @@ -0,0 +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/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/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/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/EvaluationDetailTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java new file mode 100644 index 0000000..c5f409c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/EvaluationDetailTest.java @@ -0,0 +1,84 @@ +package com.launchdarkly.sdk; + +import org.junit.Test; + +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 extends BaseTest { + @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<>(); + 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(EvaluationDetail.fromValue(value, variation, reason)); + } + testValues.add(equalValues); + } + } + } + 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()); + } +} 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..54625f8 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java @@ -0,0 +1,113 @@ +package com.launchdarkly.sdk; + +import org.junit.Test; + +import java.util.List; + +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; +import static com.launchdarkly.sdk.EvaluationReason.Kind.ERROR; +import static com.launchdarkly.sdk.EvaluationReason.Kind.FALLTHROUGH; +import static com.launchdarkly.sdk.EvaluationReason.Kind.OFF; +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; + +@SuppressWarnings("javadoc") +public class EvaluationReasonTest extends BaseTest { + @Test + public void basicProperties() { + assertEquals(OFF, EvaluationReason.off().getKind()); + assertEquals(FALLTHROUGH, EvaluationReason.fallthrough().getKind()); + assertEquals(TARGET_MATCH, EvaluationReason.targetMatch().getKind()); + assertEquals(RULE_MATCH, EvaluationReason.ruleMatch(1, "id").getKind()); + assertEquals(PREREQUISITE_FAILED, EvaluationReason.prerequisiteFailed("key").getKind()); + assertEquals(ERROR, EvaluationReason.error(FLAG_NOT_FOUND).getKind()); + + assertEquals(1, EvaluationReason.ruleMatch(1, "id").getRuleIndex()); + assertEquals(-1, EvaluationReason.off().getRuleIndex()); + assertEquals(-1, EvaluationReason.fallthrough().getRuleIndex()); + assertEquals(-1, EvaluationReason.targetMatch().getRuleIndex()); + assertEquals(-1, EvaluationReason.prerequisiteFailed("key").getRuleIndex()); + assertEquals(-1, EvaluationReason.error(FLAG_NOT_FOUND).getRuleIndex()); + + assertEquals("id", EvaluationReason.ruleMatch(1, "id").getRuleId()); + assertNull(EvaluationReason.off().getRuleId()); + assertNull(EvaluationReason.fallthrough().getRuleId()); + assertNull(EvaluationReason.targetMatch().getRuleId()); + assertNull(EvaluationReason.prerequisiteFailed("key").getRuleId()); + assertNull(EvaluationReason.error(FLAG_NOT_FOUND).getRuleId()); + + assertEquals("key", EvaluationReason.prerequisiteFailed("key").getPrerequisiteKey()); + assertNull(EvaluationReason.off().getPrerequisiteKey()); + assertNull(EvaluationReason.fallthrough().getPrerequisiteKey()); + assertNull(EvaluationReason.targetMatch().getPrerequisiteKey()); + assertNull(EvaluationReason.ruleMatch(1, "id").getPrerequisiteKey()); + assertNull(EvaluationReason.error(FLAG_NOT_FOUND).getPrerequisiteKey()); + + assertEquals(FLAG_NOT_FOUND, EvaluationReason.error(FLAG_NOT_FOUND).getErrorKind()); + assertNull(EvaluationReason.off().getErrorKind()); + assertNull(EvaluationReason.fallthrough().getErrorKind()); + assertNull(EvaluationReason.targetMatch().getErrorKind()); + assertNull(EvaluationReason.ruleMatch(1, "id").getErrorKind()); + assertNull(EvaluationReason.prerequisiteFailed("key").getErrorKind()); + + Exception e = new Exception("sorry"); + assertEquals(e, EvaluationReason.exception(e).getException()); + assertNull(EvaluationReason.off().getException()); + assertNull(EvaluationReason.fallthrough().getException()); + assertNull(EvaluationReason.targetMatch().getException()); + assertNull(EvaluationReason.ruleMatch(1, "id").getException()); + assertNull(EvaluationReason.prerequisiteFailed("key").getException()); + assertNull(EvaluationReason.error(FLAG_NOT_FOUND).getException()); + } + + @Test + public void simpleStringRepresentations() { + assertEquals("OFF", EvaluationReason.off().toString()); + assertEquals("FALLTHROUGH", EvaluationReason.fallthrough().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("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()); + assertEquals("ERROR(EXCEPTION,java.lang.Exception: something happened)", + EvaluationReason.exception(new Exception("something happened")).toString()); + } + + @Test + public void instancesAreReused() { + assertSame(EvaluationReason.off(), EvaluationReason.off()); + assertSame(EvaluationReason.fallthrough(), EvaluationReason.fallthrough()); + assertSame(EvaluationReason.targetMatch(), EvaluationReason.targetMatch()); + + for (EvaluationReason.ErrorKind errorKind: EvaluationReason.ErrorKind.values()) { + EvaluationReason r0 = EvaluationReason.error(errorKind); + assertEquals(errorKind, r0.getErrorKind()); + EvaluationReason r1 = EvaluationReason.error(errorKind); + assertSame(r0, r1); + } + } + + @Test + public void equalInstancesAreEqual() { + 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); + } +} 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..8b741fa --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/LDUserTest.java @@ -0,0 +1,337 @@ +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 { + 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; + + private 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, 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), + 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, 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 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") + .build(); + assertEquals(user, new LDUser.Builder(user).build()); + + LDUser 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(); + assertEquals(userWithCustomAttrs, new LDUser.Builder(userWithCustomAttrs).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 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/LDValueArrayTest.java b/src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java new file mode 100644 index 0000000..51fd536 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java @@ -0,0 +1,149 @@ +package com.launchdarkly.sdk; + +import org.hamcrest.Matchers; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +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.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") +public class LDValueArrayTest { + private static final LDValue anArrayValue = LDValue.buildArray().add(LDValue.of(3)).build(); + + @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 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..d338fff --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/LDValueObjectTest.java @@ -0,0 +1,149 @@ +package com.launchdarkly.sdk; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +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); + } + Collections.sort(keys); + 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()); + } + Collections.sort(values); + 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 new file mode 100644 index 0000000..af49bd9 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/LDValueTest.java @@ -0,0 +1,261 @@ +package com.launchdarkly.sdk; + +import com.launchdarkly.sdk.json.SerializationException; + +import org.junit.Test; + +import java.util.List; + +import static java.util.Arrays.asList; +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; + +@SuppressWarnings("javadoc") +public class LDValueTest extends BaseTest { + 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 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()); + assertTrue(aTrueBoolValue.booleanValue()); + } + + @Test + public void nonBooleanValueAsBooleanIsFalse() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + aStringValue, + anIntValue, + aLongValue, + aFloatValue, + aDoubleValue, + anArrayValue, + anObjectValue, + }; + for (LDValue value: values) { + String desc = value.toString(); + assertNotEquals(desc, LDValueType.BOOLEAN, value.getType()); + assertFalse(desc, 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) { + String desc = value.toString(); + assertNotEquals(desc, LDValueType.STRING, value.getType()); + assertNull(desc, 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) { + String desc = value.toString(); + assertEquals(desc, LDValueType.NUMBER, value.getType()); + assertEquals(desc, 3, value.intValue()); + assertEquals(desc, 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) { + String desc = value.toString(); + assertEquals(desc, LDValueType.NUMBER, value.getType()); + assertEquals(desc, 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) { + String desc = value.toString(); + assertEquals(desc, LDValueType.NUMBER, value.getType()); + assertEquals(desc, 3.0d, value.doubleValue(), 0); + } + } + + @Test + public void nonNumericValueAsNumberIsZero() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + aTrueBoolValue, + aStringValue, + aNumericLookingStringValue, + anArrayValue, + anObjectValue + }; + for (LDValue value: values) { + 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 equalValuesAreEqual() { + 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")) + ); + 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; + 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 parseThrowsRuntimeExceptionForMalformedJson() { + try { + LDValue.parse("{"); + } catch (RuntimeException e) { + assertThat(e.getCause(), instanceOf(SerializationException.class)); + } + } +} 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..7389cc8 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/TestHelpers.java @@ -0,0 +1,62 @@ +package com.launchdarkly.sdk; + +import java.util.ArrayList; +import java.util.HashSet; +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 { + // 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) { + list.add(t); + } + return list; + } + + public static Set setFromIterable(Iterable it) { + Set set = new HashSet<>(); + for (T t: it) { + set.add(t); + } + 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); + 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) { + 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 new file mode 100644 index 0000000..9de50b0 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java @@ -0,0 +1,91 @@ +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 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()); + } + + @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/EvaluationDetailJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java new file mode 100644 index 0000000..93634c7 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java @@ -0,0 +1,35 @@ +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; + +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.verifySerialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; + +@SuppressWarnings("javadoc") +public class EvaluationDetailJsonSerializationTest extends BaseTest { + @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\"}}"); + + 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 + // scenario is covered in ReflectiveFrameworksTest. + } +} 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..1ae82e0 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationReasonJsonSerializationTest.java @@ -0,0 +1,45 @@ +package com.launchdarkly.sdk.json; + +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.verifySerialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; + +@SuppressWarnings("javadoc") +public class EvaluationReasonJsonSerializationTest extends BaseTest { + @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.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 + 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..96fa087 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java @@ -0,0 +1,90 @@ +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.BaseTest; +import com.launchdarkly.sdk.LDValue; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +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 + // 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); + } + + 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. + assertJsonEquals(expectedJsonString, JsonSerialization.serialize(instance)); + + assertJsonEquals(expectedJsonString, configureGson().toJson(instance)); + + assertJsonEquals(expectedJsonString, configureJacksonMapper().writeValueAsString(instance)); + } + + @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 + // 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); + + T instance2 = configureGson().fromJson(expectedJsonString, objectClass); + assertEquals(instance, instance2); + + T instance3 = configureJacksonMapper().readValue(expectedJsonString, objectClass); + assertEquals(instance, instance3); + } + + 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 new file mode 100644 index 0000000..18c7334 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java @@ -0,0 +1,92 @@ +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.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"); + } + + @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") + .custom("c2", "v2") + .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").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/LDValueJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java new file mode 100644 index 0000000..55e5511 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java @@ -0,0 +1,40 @@ +package com.launchdarkly.sdk.json; + +import com.launchdarkly.sdk.BaseTest; +import com.launchdarkly.sdk.LDValue; + +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; + +@SuppressWarnings("javadoc") +public class LDValueJsonSerializationTest extends BaseTest { + @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}"); + verifyDeserializeInvalidJson(LDValue.class, "]"); + } + + private static void verifyValueSerialization(LDValue value, String expectedJsonString) throws Exception { + verifySerializeAndDeserialize(value, expectedJsonString); + assertEquals(parseElement(expectedJsonString), parseElement(value.toJsonString())); + assertEquals(value, LDValue.parse(expectedJsonString)); + } +} 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..e147a5b --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/ReflectiveFrameworksTest.java @@ -0,0 +1,113 @@ +package com.launchdarkly.sdk.json; + +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; + +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 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 + // 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 + // 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]}," + + "\"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(), makeDetailValue(), makeStringDetailValue()); + 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); + assertEquals(makeDetailValue(), o.detailValue); + assertEquals(makeStringDetailValue(), o.stringDetailValue); + } + + @Test + public void jacksonSerializesTypeContainingOurType() throws Exception { + ObjectContainingValues o = new ObjectContainingValues( + TOP_LEVEL_VALUE, makeMapOfValues(),makeDetailValue(), makeStringDetailValue()); + 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); + 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() { + Map m = new HashMap<>(); + m.put("a", LDValue.of(1)); + m.put("b", LDValue.buildArray().add(2).add(3).build()); + 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("detailValue") EvaluationDetail detailValue, + @JsonProperty("stringDetailValue") EvaluationDetail stringDetailValue) { + this.topLevelValue = topLevelValue; + this.mapOfValues = mapOfValues; + this.detailValue = detailValue; + this.stringDetailValue = stringDetailValue; + } + } +} 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..6ac6443 --- /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"); + } +}