diff --git a/.circleci/config.yml b/.circleci/config.yml index 7d3f159..7961a08 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,6 +2,7 @@ version: 2.1 orbs: win: circleci/windows@1.0.0 + android: circleci/android@1.0 workflows: test: @@ -43,6 +44,7 @@ jobs: - run: java -version - run: ./gradlew dependencies - run: ./gradlew jar + - run: ./gradlew javadoc - run: ./gradlew checkstyleMain - persist_to_workspace: root: build @@ -121,49 +123,31 @@ jobs: path: .\junit build-test-android: - # This is adapted from the CI build for android-client-sdk - macos: - xcode: "10.3.0" - shell: /bin/bash --login -eo pipefail - working_directory: ~/launchdarkly/android-client-sdk-private - environment: - TERM: dumb - QEMU_AUDIO_DRV: none - _JAVA_OPTIONS: "-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -Xms2048m -Xmx4096m" - GRADLE_OPTS: "-Dorg.gradle.daemon=false" - JVM_OPTS: -Xmx3200m - ANDROID_HOME: "/usr/local/share/android-sdk" - ANDROID_SDK_HOME: "/usr/local/share/android-sdk" - ANDROID_SDK_ROOT: "/usr/local/share/android-sdk" + executor: + name: android/android-machine + resource-class: large + steps: - checkout + + # What we want to do here is somewhat unusual: we want Android to run all of our tests from + # src/test/java, but run them in the Android emulator (to prove that we're only using Java + # APIs that our minimum Android API version supports). Normally, only tests in + # src/androidTest/java would be run that way. Also, Android needs a different JUnit test + # runner annotation on all of the test classes. So we can't just run the test code as-is. + # + # This step copies all the code from src/test/java into src/androidTest/java, except for the + # base class BaseTest.java, which is already defined in src/androidTest/java to provide the + # necessary test runner annotation. - run: - name: Install Android tools - command: ./scripts/install-android-tools.sh - - run: - name: Start Android environment - command: ./scripts/start-android-env.sh - background: true - timeout: 1200 - no_output_timeout: 20m - - run: - name: Wait for Android environment - command: ./scripts/started-android-env.sh - - run: - name: Run tests - command: ./scripts/run-android-tests.sh - no_output_timeout: 20m - - run: - name: Save test results - command: | - mkdir -p ~/test-results - cp -r ./build/outputs/androidTest-results/* ~/test-results/ - when: always - - run: - name: Stop Android environment - command: ./scripts/stop-android-env.sh - when: always + name: Copy tests + command: rsync -r ./src/test/java/ ./src/androidTest/java/ --exclude='BaseTest.java' + + - android/start-emulator-and-run-tests: + system-image: system-images;android-21;default;x86 + max-tries: 1 + post-emulator-launch-assemble-command: ./gradlew -b build-android.gradle :assembleAndroidTest + test-command: ./gradlew -b build-android.gradle :connectedAndroidTest + - store_test_results: - path: ~/test-results - - store_artifacts: - path: ~/artifacts + path: ./build/outputs/androidTest-results diff --git a/.gitignore b/.gitignore index b8a8f70..9cbae19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ # Eclipse project files .classpath .project - +.settings + # Intellij project files *.iml *.ipr diff --git a/README.md b/README.md index e1187d5..5c29584 100644 --- a/README.md +++ b/README.md @@ -26,4 +26,3 @@ See [Contributing](https://github.com/launchdarkly/dotnet-sdk-common/blob/master * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates - * [Feature Flagging Guide](https://github.com/launchdarkly/featureflags/ "Feature Flagging Guide") for best practices and strategies diff --git a/build.gradle b/build.gradle index 73ee6ad..470f688 100644 --- a/build.gradle +++ b/build.gradle @@ -87,10 +87,11 @@ jacocoTestCoverageVerification { // The key for each of these items is the complete method signature minus the "com.launchdarkly.sdk." prefix. "EvaluationReason.error(com.launchdarkly.sdk.EvaluationReason.ErrorKind)": 1, "EvaluationReasonTypeAdapter.parse(com.google.gson.stream.JsonReader)": 1, - "EvaluationDetailTypeAdapterFactory.EvaluationDetailTypeAdapter.read(com.google.gson.stream.JsonReader)": 1, "LDValue.equals(java.lang.Object)": 1, - "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)": 3, - "json.JsonSerialization.getDeserializableClasses()": -1 + "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)": 1, + "json.JsonSerialization.getDeserializableClasses()": -1, + "json.LDGson.LDTypeAdapter.write(com.google.gson.stream.JsonWriter, java.lang.Object)": 1, + "json.LDJackson.GsonReaderToJacksonParserAdapter.peekInternal()": 3 ] knownMissedLinesForMethods.each { partialSignature, maxMissedLines -> diff --git a/scripts/circleci/LICENSE b/scripts/circleci/LICENSE deleted file mode 100644 index 1311556..0000000 --- a/scripts/circleci/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2017-2019 Circle Internet Services, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/scripts/circleci/circle-android b/scripts/circleci/circle-android deleted file mode 100755 index 4524b60..0000000 --- a/scripts/circleci/circle-android +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python - -# See LICENSE file in this directory for copyright and license information -# Above LICENSE notice added 10/16/2019 by Gavin Whelan - -from sys import argv, exit, stdout -from time import sleep, time -from os import system -from subprocess import check_output, CalledProcessError -from threading import Thread, Event -from functools import partial - -class StoppableThread(Thread): - - def __init__(self): - super(StoppableThread, self).__init__() - self._stop_event = Event() - self.daemon = True - - def stopped(self): - return self._stop_event.is_set() - - def run(self): - while not self.stopped(): - stdout.write('.') - stdout.flush() - sleep(2) - - def stop(self): - self._stop_event.set() - -def shell_getprop(name): - try: - return check_output(['adb', 'shell', 'getprop', name]).strip() - except CalledProcessError as e: - return '' - -start_time = time() - -def wait_for(name, fn): - stdout.write('Waiting for %s' % name) - spinner = StoppableThread() - spinner.start() - stdout.flush() - while True: - if fn(): - spinner.stop() - time_taken = int(time() - start_time) - print('\n%s is ready after %d seconds' % (name, time_taken)) - break - sleep(1) - -def device_ready(): - return system('adb wait-for-device') == 0 - -def shell_ready(): - return system('adb shell true &> /dev/null') == 0 - -def prop_has_value(prop, value): - return shell_getprop(prop) == value - -def wait_for_sys_prop(name, prop, value): - # return shell_getprop('init.svc.bootanim') == 'stopped' - wait_for(name, partial(prop_has_value, prop, value)) - -usage = """ -%s, a collection of tools for CI with android. - -Usage: - %s wait-for-boot - wait for a device to fully boot. - (adb wait-for-device only waits for it to be ready for shell access). -""" - -if __name__ == "__main__": - - if len(argv) != 2 or argv[1] != 'wait-for-boot': - print(usage % (argv[0], argv[0])) - exit(0) - - wait_for('Device', device_ready) - wait_for('Shell', shell_ready) - wait_for_sys_prop('Boot animation complete', 'init.svc.bootanim', 'stopped') - wait_for_sys_prop('Boot animation exited', 'service.bootanim.exit', '1') - wait_for_sys_prop('System boot complete', 'sys.boot_completed', '1') - wait_for_sys_prop('GSM Ready', 'gsm.sim.state', 'READY') - #wait_for_sys_prop('init.svc.clear-bcb' ,'init.svc.clear-bcb', 'stopped') - - diff --git a/scripts/install-android-tools.sh b/scripts/install-android-tools.sh deleted file mode 100755 index 3df504d..0000000 --- a/scripts/install-android-tools.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash - -set -e -x -set +o pipefail # necessary because of how we're using "yes |" - -echo 'export PATH="$PATH:/usr/local/share/android-sdk/tools/bin"' >> $BASH_ENV -echo 'export PATH="$PATH:/usr/local/share/android-sdk/platform-tools"' >> $BASH_ENV - -HOMEBREW_NO_AUTO_UPDATE=1 brew tap homebrew/cask -HOMEBREW_NO_AUTO_UPDATE=1 brew cask install android-sdk - -yes | sdkmanager "platform-tools" \ - "platforms;android-19" \ - "extras;intel;Hardware_Accelerated_Execution_Manager" \ - "build-tools;26.0.2" \ - "system-images;android-19;default;x86" \ - "emulator" | grep -v = || true - -yes | sdkmanager --licenses - -echo no | avdmanager create avd -n ci-android-avd -f -k "system-images;android-19;default;x86" - -./gradlew -b build-android.gradle androidDependencies diff --git a/scripts/run-android-tests.sh b/scripts/run-android-tests.sh deleted file mode 100755 index 9592d84..0000000 --- a/scripts/run-android-tests.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -# What we want to do here is somewhat unusual: we want Android to run all of our tests from -# src/test/java, but run them in the Android emulator (to prove that we're only using Java APIs -# that our minimum Android API version supports). Normally, only tests in src/androidTest/java -# would be run that way. Also, Android needs a different JUnit test runner annotation on all of -# the test classes. So we can't just run the test code as-is. -# -# This script copies all the code from src/test/java into src/androidTest/java, except for the -# base class BaseTest.java, which is already defined in src/androidTest/java to provide the -# necessary test runner annotation. Then it runs the tests in the already-started emulator. - -set -x -e -o pipefail - -rsync -r ./src/test/java/ ./src/androidTest/java/ --exclude='BaseTest.java' - -./gradlew -b build-android.gradle :connectedAndroidTest --console=plain -PdisablePreDex diff --git a/scripts/start-android-env.sh b/scripts/start-android-env.sh deleted file mode 100755 index ced38d7..0000000 --- a/scripts/start-android-env.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -set -x -e -o pipefail - -unset ANDROID_NDK_HOME - -$ANDROID_HOME/emulator/emulator -avd ci-android-avd \ - -netdelay none -netspeed full -no-audio -no-window -no-snapshot -no-boot-anim diff --git a/scripts/started-android-env.sh b/scripts/started-android-env.sh deleted file mode 100755 index 9b009e7..0000000 --- a/scripts/started-android-env.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -set -x -e - -$(dirname $0)/circleci/circle-android wait-for-boot - -while ! adb shell getprop ro.build.version.sdk; do - sleep 1 -done diff --git a/scripts/stop-android-env.sh b/scripts/stop-android-env.sh deleted file mode 100755 index 31d085d..0000000 --- a/scripts/stop-android-env.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -x -e -o pipefail - -adb emu kill || true diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java index b6d66c7..f151b49 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetailTypeAdapterFactory.java @@ -59,6 +59,7 @@ public void write(JsonWriter out, EvaluationDetail value) throws IOException out.endObject(); } + @SuppressWarnings("unchecked") @Override public EvaluationDetail read(JsonReader in) throws IOException { T value = null; @@ -85,8 +86,10 @@ public EvaluationDetail read(JsonReader in) throws IOException { } in.endObject(); + if (value == null && valueType == LDValue.class) { + value = (T)LDValue.ofNull(); // normalize to get around gson's habit of skipping the TypeAdapter for nulls + } return EvaluationDetail.fromValue(value, variation, reason); } - } } diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java index a505eb9..389f0d8 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReasonTypeAdapter.java @@ -8,7 +8,7 @@ import java.io.IOException; -import static com.launchdarkly.sdk.Helpers.readNonNullableString; +import static com.launchdarkly.sdk.Helpers.readEnum; import static com.launchdarkly.sdk.Helpers.readNullableString; final class EvaluationReasonTypeAdapter extends TypeAdapter { @@ -29,7 +29,7 @@ static EvaluationReason parse(JsonReader reader) throws IOException { String key = reader.nextName(); switch (key) { // COVERAGE: may have spurious "branches missed" warning, see https://stackoverflow.com/questions/28013717/eclemma-branch-coverage-for-switch-7-of-19-missed case "kind": - kind = Enum.valueOf(EvaluationReason.Kind.class, readNonNullableString(reader)); + kind = readEnum(EvaluationReason.Kind.class, reader); break; case "ruleIndex": ruleIndex = reader.nextInt(); @@ -41,7 +41,7 @@ static EvaluationReason parse(JsonReader reader) throws IOException { prereqKey = reader.nextString(); break; case "errorKind": - errorKind = Enum.valueOf(EvaluationReason.ErrorKind.class, readNonNullableString(reader)); + errorKind = readEnum(EvaluationReason.ErrorKind.class, reader); break; default: reader.skipValue(); // ignore any unexpected property diff --git a/src/main/java/com/launchdarkly/sdk/Helpers.java b/src/main/java/com/launchdarkly/sdk/Helpers.java index f602d0b..5231aef 100644 --- a/src/main/java/com/launchdarkly/sdk/Helpers.java +++ b/src/main/java/com/launchdarkly/sdk/Helpers.java @@ -56,4 +56,13 @@ static String readNonNullableString(JsonReader reader) throws IOException { throw new JsonParseException("expected string value"); } } + + static > T readEnum(Class enumClass, JsonReader reader) throws IOException { + String s = readNonNullableString(reader); + try { + return Enum.valueOf(enumClass, s); + } catch (IllegalArgumentException e) { + throw new JsonParseException(String.format("unsupported value \"%s\" for %s", s, enumClass)); + } + } } diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index b358025..956dc7f 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.json.SerializationException; import java.io.IOException; +import java.util.Arrays; import java.util.Map; import static com.launchdarkly.sdk.Helpers.transform; @@ -133,9 +134,9 @@ public static LDValue of(String value) { } /** - * Starts building an array value. + * Starts building an array value. The elements can be of any type supported by LDValue. *

-   *     LDValue arrayOfInts = LDValue.buildArray().add(LDValue.int(1), LDValue.int(2)).build():
+   *     LDValue arrayOfInts = LDValue.buildArray().add(2).add("three").build():
    * 
* If the values are all of the same type, you may also use {@link LDValue.Converter#arrayFrom(Iterable)} * or {@link LDValue.Converter#arrayOf(Object...)}. @@ -146,6 +147,21 @@ public static ArrayBuilder buildArray() { return new ArrayBuilder(); } + /** + * Creates an array value from the specified values. The elements can be of any type supported by LDValue. + *

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

@@ -176,7 +192,7 @@ public static ObjectBuilder buildObject() {
    */
   public static LDValue parse(String json) {
     try {
-      return JsonSerialization.deserialize(json, LDValue.class);
+      return LDValue.normalize(JsonSerialization.deserialize(json, LDValue.class));
     } catch (SerializationException e) {
       throw new RuntimeException(e);
     }
diff --git a/src/main/java/com/launchdarkly/sdk/LDValueArray.java b/src/main/java/com/launchdarkly/sdk/LDValueArray.java
index 3c84fc5..c9675fa 100644
--- a/src/main/java/com/launchdarkly/sdk/LDValueArray.java
+++ b/src/main/java/com/launchdarkly/sdk/LDValueArray.java
@@ -13,10 +13,9 @@
 final class LDValueArray extends LDValue {
   private static final LDValueArray EMPTY = new LDValueArray(Collections.emptyList());
   private final List list;
-  // Note that this is not  
 
   static LDValueArray fromList(List list) {
-    return list.isEmpty() ? EMPTY : new LDValueArray(list);
+    return list == null || list.isEmpty() ? EMPTY : new LDValueArray(list);
   }
 
   private LDValueArray(List list) {
diff --git a/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java
index bd5f419..88eb956 100644
--- a/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java
+++ b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java
@@ -35,7 +35,6 @@ public LDValue read(JsonReader reader) throws IOException {
     case BOOLEAN:
       return LDValue.of(reader.nextBoolean());
     case NULL:
-      // COVERAGE: this branch won't be reached because Gson does not call the TypeAdapter when there's a null.
       reader.nextNull();
       return LDValue.ofNull();
     case NUMBER:
@@ -43,7 +42,8 @@ public LDValue read(JsonReader reader) throws IOException {
     case STRING:
       return LDValue.of(reader.nextString());
     default:
-      // COVERAGE: this branch won't be reached because Gson does not call the TypeAdapter if the next token isn't well-formed JSON.
+      // COVERAGE: this branch won't be reached because Gson does not call the TypeAdapter if the next token
+      // isn't any of the above.
       return null;
     }
   }
diff --git a/src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java b/src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java
new file mode 100644
index 0000000..e96fc28
--- /dev/null
+++ b/src/main/java/com/launchdarkly/sdk/json/GsonReaderAdapter.java
@@ -0,0 +1,91 @@
+package com.launchdarkly.sdk.json;
+
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+
+import java.io.CharArrayReader;
+import java.io.IOException;
+import java.io.Reader;
+
+// This type is a bridge between the Gson classes on the application classpath and the Gson classes
+// that are used internally.
+//
+// In some SDK distributions, there is an internal set of Gson classes that have modified (shaded)
+// class names, to ensure that the SDK can use its own version of Gson without conflicting with the
+// application. If so, all references to Gson classes in the SDK code will be transformed to the
+// shaded class names *except* within the LDGson class. This means that our Gson TypeAdapters can't
+// interact directly with a JsonReader or JsonWriter that is provided by the application.
+//
+// GsonReaderAdapter and GsonWriterAdapter, since they are declared outside of the LDGson class,
+// *will* have all Gson types in their class/method signatures shaded if we are using shading.
+// Therefore, they can be used with our internal Gson logic. But the actual implementation of their
+// methods is done by a subclass that is an inner class of LDGson-- so, that class can interact
+// with unshaded Gson classes provided by the application.
+//
+// So, if all com.google.gson classes are being shaded to com.launchdarkly.shaded.com.google.gson,
+// then the base class of GsonReaderAdapter is com.launchdarkly.shaded.com.google.gson.JsonReader;
+// the class LDGson.DelegatingJsonReaderAdapter is a GsonReaderAdapter, so it is also a
+// com.launchdarkly.shaded.com.google.gson.JsonReader; but references to JsonReader within the
+// implementation of LDGson.DelegatingJsonReaderAdapter are to com.google.json.JsonReader.
+//
+// In SDK distributions that do not use shading, these types are not really necessary, but their
+// overhead is minimal so we use them in all cases.
+abstract class GsonReaderAdapter extends JsonReader {
+  private static final JsonToken[] TOKEN_VALUES = JsonToken.values();
+  
+  GsonReaderAdapter() {
+    super(makeStubReader());
+  }
+
+  private static final Reader makeStubReader() {
+    // The JsonReader constructor requires a non-null Reader, but we won't actually be using it.
+    // Unfortunately Java 7 doesn't implement a completely no-op Reader. 
+    return new CharArrayReader(new char[0]);
+  }
+  
+  @Override
+  abstract public void beginArray() throws IOException;
+  
+  @Override
+  abstract public void beginObject() throws IOException;
+  
+  @Override
+  abstract public void endArray() throws IOException;
+  
+  @Override
+  abstract public void endObject() throws IOException;
+  
+  @Override
+  abstract public boolean hasNext() throws IOException;
+  
+  @Override
+  abstract public boolean nextBoolean() throws IOException;
+  
+  @Override
+  abstract public double nextDouble() throws IOException;
+  
+  @Override
+  abstract public int nextInt() throws IOException;
+  
+  @Override
+  abstract public long nextLong() throws IOException;
+  
+  @Override
+  abstract public String nextName() throws IOException;
+  
+  @Override
+  abstract public void nextNull() throws IOException;
+  
+  @Override
+  abstract public String nextString() throws IOException;
+  
+  @Override
+  public JsonToken peek() throws IOException {
+    return TOKEN_VALUES[peekInternal()];
+  }
+  
+  @Override
+  abstract public void skipValue() throws IOException;
+  
+  abstract protected int peekInternal() throws IOException; // should return the ordinal of the JsonToken enum
+}
diff --git a/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java b/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java
new file mode 100644
index 0000000..29e4236
--- /dev/null
+++ b/src/main/java/com/launchdarkly/sdk/json/GsonWriterAdapter.java
@@ -0,0 +1,140 @@
+package com.launchdarkly.sdk.json;
+
+import com.google.gson.stream.JsonWriter;
+
+import java.io.CharArrayWriter;
+import java.io.IOException;
+import java.io.Writer;
+
+// This type is a bridge between the Gson classes on the application classpath and the Gson classes
+// that are used internally.
+//
+// In some SDK distributions, there is an internal set of Gson classes that have modified (shaded)
+// class names, to ensure that the SDK can use its own version of Gson without conflicting with the
+// application. If so, all references to Gson classes in the SDK code will be transformed to the
+// shaded class names *except* within the LDGson class. This means that our Gson TypeAdapters can't
+// interact directly with a JsonReader or JsonWriter that is provided by the application.
+//
+// GsonReaderAdapter and GsonWriterAdapter, since they are declared outside of the LDGson class,
+// *will* have all Gson types in their class/method signatures shaded if we are using shading.
+// Therefore, they can be used with our internal Gson logic. But the actual implementation of their
+// methods is done by a subclass that is an inner class of LDGson-- so, that class can interact
+// with unshaded Gson classes provided by the application.
+//
+// So, if all com.google.gson classes are being shaded to com.launchdarkly.shaded.com.google.gson,
+// then the base class of GsonWriterAdapter is com.launchdarkly.shaded.com.google.gson.JsonWriter;
+// the class LDGson.DelegatingJsonWriterAdapter is a GsonWriterAdapter, so it is also a
+// com.launchdarkly.shaded.com.google.gson.JsonWriter; but references to JsonWriter within the
+// implementation of LDGson.DelegatingJsonWriterAdapter are to com.google.json.JsonWriter.
+//
+// In SDK distributions that do not use shading, these types are not really necessary, but their
+// overhead is minimal so we use them in all cases.
+abstract class GsonWriterAdapter extends JsonWriter {
+  GsonWriterAdapter() {
+    super(makeStubWriter());
+  }
+  
+  private static final Writer makeStubWriter() {
+    // The JsonWriter constructor requires a non-null Writer, but we won't actually be using it.
+    // Unfortunately Java 7 doesn't implement a completely no-op Writer. 
+    return new CharArrayWriter(0);
+  }
+  
+  @Override
+  public JsonWriter beginArray() throws IOException {
+    beginArrayInternal();
+    return this;
+  }
+  
+  @Override
+  public JsonWriter beginObject() throws IOException {
+    beginObjectInternal();
+    return this;
+  }
+  
+  @Override
+  public JsonWriter endArray() throws IOException {
+    endArrayInternal();
+    return this;
+  }
+  
+  @Override
+  public JsonWriter endObject() throws IOException {
+    endObjectInternal();
+    return this;
+  }
+  
+  @Override
+  public JsonWriter jsonValue(String value) throws IOException {
+    jsonValueInternal(value);
+    return this;
+  }
+  
+  @Override
+  public JsonWriter name(String name) throws IOException {
+    nameInternal(name);
+    return this;
+  }
+  
+  @Override
+  public JsonWriter nullValue() throws IOException {
+    valueInternalNull();
+    return this;
+  }
+
+  @Override
+  public JsonWriter value(boolean value) throws IOException {
+    valueInternalBool(value);
+    return this;
+  }
+  
+  @Override
+  public JsonWriter value(Boolean value) throws IOException {
+    if (value == null) {
+      valueInternalNull();
+    } else {
+      valueInternalBool(value.booleanValue());
+    }
+    return this;
+  }
+  
+  @Override
+  public JsonWriter value(double value) throws IOException {
+    valueInternalDouble(value);
+    return this;
+  }
+
+  @Override
+  public JsonWriter value(long value) throws IOException {
+    valueInternalLong(value);
+    return this;
+  }
+
+  @Override
+  public JsonWriter value(Number value) throws IOException {
+    valueInternalNumber(value);
+    return this;
+  }
+  
+  @Override
+  public JsonWriter value(String value) throws IOException {
+    valueInternalString(value);
+    return this;
+  }
+  
+  @Override
+  public void close() throws IOException {}
+  
+  protected abstract void beginArrayInternal() throws IOException;
+  protected abstract void beginObjectInternal() throws IOException;
+  protected abstract void endArrayInternal() throws IOException;
+  protected abstract void endObjectInternal() throws IOException;
+  protected abstract void jsonValueInternal(String value) throws IOException;
+  protected abstract void nameInternal(String name) throws IOException;
+  protected abstract void valueInternalNull() throws IOException;
+  protected abstract void valueInternalBool(boolean value) throws IOException;
+  protected abstract void valueInternalDouble(double value) throws IOException;
+  protected abstract void valueInternalLong(long value) throws IOException;
+  protected abstract void valueInternalNumber(Number value) throws IOException;
+  protected abstract void valueInternalString(String value) throws IOException;
+}
diff --git a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java
index 63b8169..8cadeb2 100644
--- a/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java
+++ b/src/main/java/com/launchdarkly/sdk/json/JsonSerialization.java
@@ -35,7 +35,7 @@ private JsonSerialization() {}
   
   static final List> knownDeserializableClasses = new ArrayList<>();
   
-  static final Gson gson = new Gson();
+  private static final Gson gson = new Gson();
   
   /**
    * Converts an object to its JSON representation.
@@ -86,13 +86,31 @@ static  T deserializeInternal(String json, Class objectClass) throws Seria
     }
   }
 
-  // Used internally from LDGson
-  static  T deserializeInternalGson(String json, Type objectType) throws SerializationException {
-    try {
-      return gson.fromJson(json, objectType);
-    } catch (Exception e) {
-      throw new SerializationException(e);
-    }
+  // Used internally to delegate to gson.toJson() in a way that will work correctly regardless of
+  // whether we're shading the Gson types or not.
+  //
+  // The issue is this. In the Java SDK, all references to Gson types anywhere in the SDK *except*
+  // in the LDGson class will have their packages rewritten from com.google.gson to
+  // com.launchdarkly.shaded.com.google.gson. That's the whole reason GsonWriterAdapter exists.
+  // However, the shading logic is not quite smart enough to adjust method signatures that have
+  // already been copied into non-shaded classes, so if the LDGson code (which is immune from
+  // shading) tries to call any methods on JsonSerialization.gson (which was originally an
+  // instance of c.g.gson.Gson, but now is an instance of c.l.s.c.g.gson.Gson)-- or tries to call
+  // any method that took a parameter of type c.g.gson.stream.JsonWriter, but has since been
+  // rewritten to take a parameter of type c.l.s.c.g.gson.stream.JsonWriter-- the call will fail
+  // because the actual method signature doesn't match what the caller expected.
+  //
+  // The solution is to add this delegating method whose external surface doesn't contain any
+  // references to classes whose package names will be rewritten; while JsonSerialization and
+  // GsonWriterAdapter will have code *inside* them modified by shading, their own signatures
+  // won't change.
+  static void serializeToGsonInternal(Object value, Class type, GsonWriterAdapter writer) {
+    gson.toJson(value, type, writer);
+  }
+  
+  // See comment on serializeToGsonInternal.
+  static  T deserializeFromGsonInternal(GsonReaderAdapter adapter, Type type) {
+    return gson.fromJson(adapter, type);
   }
   
   /**
diff --git a/src/main/java/com/launchdarkly/sdk/json/LDGson.java b/src/main/java/com/launchdarkly/sdk/json/LDGson.java
index 5878e4e..28ea231 100644
--- a/src/main/java/com/launchdarkly/sdk/json/LDGson.java
+++ b/src/main/java/com/launchdarkly/sdk/json/LDGson.java
@@ -1,8 +1,11 @@
 package com.launchdarkly.sdk.json;
 
 import com.google.gson.Gson;
+import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
-import com.google.gson.JsonParseException;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
 import com.google.gson.TypeAdapter;
 import com.google.gson.TypeAdapterFactory;
 import com.google.gson.reflect.TypeToken;
@@ -13,6 +16,8 @@
 
 import java.io.IOException;
 import java.lang.reflect.Type;
+import java.util.HashMap;
+import java.util.Map;
 
 /**
  * A helper class for interoperability with application code that uses Gson.
@@ -50,6 +55,9 @@
  * exception if Gson is not in the caller's classpath.
  */
 public abstract class LDGson {
+  private static final JsonElement JSONELEMENT_TRUE = new JsonPrimitive(true);
+  private static final JsonElement JSONELEMENT_FALSE = new JsonPrimitive(false);
+  
   private LDGson() {}
   
   // Implementation note:
@@ -79,6 +87,57 @@ public static TypeAdapterFactory typeAdapters() {
     return LDTypeAdapterFactory.INSTANCE;
   }
   
+  /**
+   * Returns a Gson {@code JsonElement} that is equivalent to the specified {@link LDValue}.
+   * 

+ * This is slightly more efficient than using {@code Gson.toJsonTree()}. + * + * @param value an {@link LDValue} ({@code null} is treated as equivalent to {@link LDValue#ofNull()}) + * @return a Gson {@code JsonElement} (may be a {@code JsonNull} but will never be {@code null}) + */ + public static JsonElement valueToJsonElement(LDValue value) { + if (value == null) { + return JsonNull.INSTANCE; + } + switch (value.getType()) { + case BOOLEAN: + return value.booleanValue() ? JSONELEMENT_TRUE : JSONELEMENT_FALSE; + case NUMBER: + return new JsonPrimitive(value.doubleValue()); + case STRING: + return value.stringValue() == null ? JsonNull.INSTANCE : new JsonPrimitive(value.stringValue()); + case ARRAY: + JsonArray a = new JsonArray(); + for (LDValue e: value.values()) { + a.add(valueToJsonElement(e)); + } + return a; + case OBJECT: + JsonObject o = new JsonObject(); + for (String k: value.keys()) { + o.add(k, valueToJsonElement(value.get(k))); + } + return o; + default: + return JsonNull.INSTANCE; + } + } + + /** + * Convenience method for converting a map of {@link LDValue} values to a map of Gson {@code JsonElement}s. + * + * @param type of the map's keys + * @param valueMap a map containing {@link LDValue} values + * @return an equivalent map containing Gson {@code JsonElement} values + */ + public static Map valueMapToJsonElementMap(Map valueMap) { + Map ret = new HashMap<>(valueMap.size()); + for (Map.Entry e: valueMap.entrySet()) { + ret.put(e.getKey(), valueToJsonElement(e.getValue())); + } + return ret; + } + private static class LDTypeAdapterFactory implements TypeAdapterFactory { // Note that this static initializer will only run if application code actually references LDGson. private static LDTypeAdapterFactory INSTANCE = new LDTypeAdapterFactory(); @@ -86,41 +145,181 @@ private static class LDTypeAdapterFactory implements TypeAdapterFactory { @Override public TypeAdapter create(Gson gson, TypeToken type) { if (JsonSerializable.class.isAssignableFrom(type.getRawType())) { - return new LDTypeAdapter(gson, type.getType()); + return new LDTypeAdapter(type.getType()); } return null; } } private static class LDTypeAdapter extends TypeAdapter { - private final Gson gson; private final Type objectType; - LDTypeAdapter(Gson gson, Type objectType) { - this.gson = gson; + LDTypeAdapter(Type objectType) { this.objectType = objectType; } @Override public void write(JsonWriter out, T value) throws IOException { - String json = JsonSerialization.serializeInternal(value); - out.jsonValue(json); + if (value == null) { + // COVERAGE: we don't expect this to ever happen, since Gson normally doesn't bother to call + // the type adapter for any null value; it's just a sanity check. + out.nullValue(); + } else { + JsonSerialization.serializeToGsonInternal(value, value.getClass(), new DelegatingJsonWriterAdapter(out)); + } } @Override public T read(JsonReader in) throws IOException { - // This implementation is inefficient because we can't assume our internal Gson instance can - // use this JsonReader directly; instead we have to read the next JSON value, convert it to a - // string, and then ask our JsonSerialization to parse it back from a string. - JsonElement jsonTree = gson.fromJson(in, JsonElement.class); - String jsonString = gson.toJson(jsonTree); - try { - // Calling the Gson overload that takes a Type rather than a Class (even though a Class *is* a - // Type) allows it to take generic type parameters into account for EvaluationDetail. - return JsonSerialization.deserializeInternalGson(jsonString, objectType); - } catch (SerializationException e) { - throw new JsonParseException(e.getCause()); - } + return JsonSerialization.deserializeFromGsonInternal(new DelegatingJsonReaderAdapter(in), objectType); + } + } + + // See comments on GsonReaderAdapter for the reason this type exists. + static class DelegatingJsonReaderAdapter extends GsonReaderAdapter { + private final JsonReader reader; + + DelegatingJsonReaderAdapter(JsonReader reader) { + this.reader = reader; + } + + @Override + public void beginArray() throws IOException { + reader.beginArray(); + } + + @Override + public void beginObject() throws IOException { + reader.beginObject(); + } + + @Override + public void endArray() throws IOException { + reader.endArray(); + } + + @Override + public void endObject() throws IOException { + reader.endObject(); + } + + @Override + public boolean hasNext() throws IOException { + return reader.hasNext(); + } + + @Override + public boolean nextBoolean() throws IOException { + return reader.nextBoolean(); + } + + @Override + public double nextDouble() throws IOException { + return reader.nextDouble(); + } + + @Override + public int nextInt() throws IOException { + return reader.nextInt(); + } + + @Override + public long nextLong() throws IOException { + return reader.nextLong(); + } + + @Override + public String nextName() throws IOException { + return reader.nextName(); + } + + @Override + public void nextNull() throws IOException { + reader.nextNull(); + } + + @Override + public String nextString() throws IOException { + return reader.nextString(); + } + + @Override + public void skipValue() throws IOException { + reader.skipValue(); + } + + @Override + protected int peekInternal() throws IOException { + return reader.peek().ordinal(); + } + } + + // See comments on GsonWriterAdapter for the reason this type exists. + static class DelegatingJsonWriterAdapter extends GsonWriterAdapter { + private final JsonWriter writer; + + DelegatingJsonWriterAdapter(JsonWriter writer) { + this.writer = writer; + } + + @Override + protected void beginArrayInternal() throws IOException { + writer.beginArray(); + } + + @Override + protected void beginObjectInternal() throws IOException { + writer.beginObject(); + } + + @Override + protected void endArrayInternal() throws IOException { + writer.endArray(); + } + + @Override + protected void endObjectInternal() throws IOException { + writer.endObject(); + } + + @Override + protected void jsonValueInternal(String value) throws IOException { + writer.jsonValue(value); + } + + @Override + protected void nameInternal(String name) throws IOException { + writer.name(name); + } + + @Override + protected void valueInternalNull() throws IOException { + writer.nullValue(); + } + + @Override + protected void valueInternalBool(boolean value) throws IOException { + writer.value(value); + } + + @Override + protected void valueInternalDouble(double value) throws IOException { + writer.value(value); + } + + @Override + protected void valueInternalLong(long value) throws IOException { + writer.value(value); + } + + @Override + protected void valueInternalNumber(Number value) throws IOException { + writer.value(value); + } + + @Override + protected void valueInternalString(String value) throws IOException { + writer.value(value); } } } diff --git a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java index 56541bd..7e5f102 100644 --- a/src/main/java/com/launchdarkly/sdk/json/LDJackson.java +++ b/src/main/java/com/launchdarkly/sdk/json/LDJackson.java @@ -1,11 +1,10 @@ package com.launchdarkly.sdk.json; import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonLocation; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.TreeNode; +import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonSerializer; @@ -73,8 +72,9 @@ private static class LDJacksonSerializer extends JsonSerializer extends J @Override public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException { - // This implementation is inefficient because our internal Gson instance can't use Jackson's - // streaming parser directly; instead we have to read the next JSON value, convert it to a - // string, and then ask our JsonSerialization to parse it back from a string. - JsonLocation loc = p.getCurrentLocation(); - TreeNode jsonTree = p.readValueAsTree(); - String jsonString = jsonTree.toString(); - try { - return JsonSerialization.deserialize(jsonString, objectClass); - } catch (SerializationException e) { - throw new JsonParseException(p, "invalid JSON encoding for " + objectClass.getSimpleName(), loc, e); + try (GsonReaderToJacksonParserAdapter adapter = new GsonReaderToJacksonParserAdapter(p)) { + try { + return JsonSerialization.deserializeFromGsonInternal(adapter, objectClass); + } catch (com.google.gson.JsonParseException e) { + throw new JsonParseException(p, e.getMessage()); + } + } + } + } + + static class GsonReaderToJacksonParserAdapter extends GsonReaderAdapter { + private final JsonParser parser; + private boolean atToken = true; + + GsonReaderToJacksonParserAdapter(JsonParser parser) { + this.parser = parser; + } + + @Override + public void beginArray() throws IOException { + requireToken(JsonToken.START_ARRAY, JsonToken.START_ARRAY, "array"); + } + + @Override + public void beginObject() throws IOException { + requireToken(JsonToken.START_OBJECT, JsonToken.START_OBJECT, "object"); + } + + @Override + public void endArray() throws IOException { + requireToken(JsonToken.END_ARRAY, JsonToken.END_ARRAY, "end of array"); + } + + @Override + public void endObject() throws IOException { + requireToken(JsonToken.END_OBJECT, JsonToken.END_OBJECT, "end of object"); + } + + @Override + public boolean hasNext() throws IOException { + JsonToken t = peekToken(); + return t != JsonToken.END_ARRAY && t != JsonToken.END_OBJECT; + } + + @Override + public boolean nextBoolean() throws IOException { + requireToken(JsonToken.VALUE_FALSE, JsonToken.VALUE_TRUE, "boolean"); + return parser.getBooleanValue(); + } + + @Override + public double nextDouble() throws IOException { + requireToken(JsonToken.VALUE_NUMBER_FLOAT, JsonToken.VALUE_NUMBER_INT, "number"); + return parser.getDoubleValue(); + } + + @Override + public int nextInt() throws IOException { + requireToken(JsonToken.VALUE_NUMBER_FLOAT, JsonToken.VALUE_NUMBER_INT, "number"); + return parser.getIntValue(); + } + + @Override + public long nextLong() throws IOException { + requireToken(JsonToken.VALUE_NUMBER_FLOAT, JsonToken.VALUE_NUMBER_INT, "number"); + return parser.getLongValue(); + } + + @Override + public String nextName() throws IOException { + requireToken(JsonToken.FIELD_NAME, JsonToken.FIELD_NAME, "property name"); + return parser.getCurrentName(); + } + + @Override + public void nextNull() throws IOException { + requireToken(JsonToken.VALUE_NULL, JsonToken.VALUE_NULL, "null"); + } + + @Override + public String nextString() throws IOException { + requireToken(JsonToken.VALUE_STRING, JsonToken.VALUE_NULL, "string"); + return parser.getValueAsString(); + } + + @Override + public void skipValue() throws IOException { + consumeToken(); + parser.skipChildren(); + } + + @Override + protected int peekInternal() throws IOException { + JsonToken t = peekToken(); + if (t == null) { + return com.google.gson.stream.JsonToken.END_DOCUMENT.ordinal(); + } + com.google.gson.stream.JsonToken gt; + switch (t) { + case END_ARRAY: + gt = com.google.gson.stream.JsonToken.END_ARRAY; + break; + case END_OBJECT: + gt = com.google.gson.stream.JsonToken.END_OBJECT; + break; + case FIELD_NAME: + gt = com.google.gson.stream.JsonToken.NAME; + break; + case NOT_AVAILABLE: + gt = com.google.gson.stream.JsonToken.END_DOCUMENT; // COVERAGE: shouldn't be reachable + break; + case START_ARRAY: + gt = com.google.gson.stream.JsonToken.BEGIN_ARRAY; + break; + case START_OBJECT: + gt = com.google.gson.stream.JsonToken.BEGIN_OBJECT; + break; + case VALUE_FALSE: + gt = com.google.gson.stream.JsonToken.BOOLEAN; + break; + case VALUE_NULL: + gt = com.google.gson.stream.JsonToken.NULL; + break; + case VALUE_NUMBER_FLOAT: + gt = com.google.gson.stream.JsonToken.NUMBER; + break; + case VALUE_NUMBER_INT: + gt = com.google.gson.stream.JsonToken.NUMBER; + break; + case VALUE_STRING: + gt = com.google.gson.stream.JsonToken.STRING; + break; + case VALUE_TRUE: + gt = com.google.gson.stream.JsonToken.BOOLEAN; + break; + default: + gt = com.google.gson.stream.JsonToken.END_DOCUMENT; // COVERAGE: shouldn't be reachable } + return gt.ordinal(); + } + + private void requireToken(JsonToken type, JsonToken alternateType, String expectedDesc) throws IOException { + JsonToken t = consumeToken(); + if (t != type && t != alternateType) { + throw new JsonParseException(parser, "expected " + expectedDesc); + } + } + + private JsonToken peekToken() throws IOException { + if (!atToken) { + atToken = true; + return parser.nextToken(); + } + return parser.currentToken(); + } + + private JsonToken consumeToken() throws IOException { + if (atToken) { + atToken = false; + return parser.currentToken(); + } + return parser.nextToken(); + } + } + + static class GsonWriterToJacksonGeneratorAdapter extends GsonWriterAdapter { + private final JsonGenerator gen; + + GsonWriterToJacksonGeneratorAdapter(JsonGenerator gen) { + this.gen = gen; + } + + @Override + protected void beginArrayInternal() throws IOException { + gen.writeStartArray(); + } + + @Override + protected void beginObjectInternal() throws IOException { + gen.writeStartObject(); + } + + @Override + protected void endArrayInternal() throws IOException { + gen.writeEndArray(); + } + + @Override + protected void endObjectInternal() throws IOException { + gen.writeEndObject(); + } + + @Override + protected void jsonValueInternal(String value) throws IOException { + gen.writeRawValue(value); + } + + @Override + protected void nameInternal(String name) throws IOException { + gen.writeFieldName(name); + } + + @Override + protected void valueInternalNull() throws IOException { + gen.writeNull(); + } + + @Override + protected void valueInternalBool(boolean value) throws IOException { + gen.writeBoolean(value); + } + + @Override + protected void valueInternalDouble(double value) throws IOException { + gen.writeNumber(value); + } + + @Override + protected void valueInternalLong(long value) throws IOException { + gen.writeNumber(value); + } + + @Override + protected void valueInternalNumber(Number value) throws IOException { + if (value == null) { + gen.writeNull(); + } else { + gen.writeNumber(value.doubleValue()); + } + } + + @Override + protected void valueInternalString(String value) throws IOException { + gen.writeString(value); } } } diff --git a/src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java b/src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java index ead8933..bb4b2e5 100644 --- a/src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueArrayTest.java @@ -110,8 +110,7 @@ public void nullsInArrayAreAlwaysNullValueInstancesNotJavaNulls() { } @Test - public void equalValuesAreEqual() - { + public void equalValuesAreEqual() { List> testValues = asList( asList(LDValue.buildArray().build(), LDValue.buildArray().build()), asList(LDValue.buildArray().add("a").build(), LDValue.buildArray().add("a").build()), @@ -127,6 +126,16 @@ public void equalValuesAreEqual() TestHelpers.doEqualityTests(testValues); } + @Test + public void arrayOf() { + assertEquals(LDValue.buildArray().add(LDValue.of(2)).add(LDValue.of("three")).build(), + LDValue.arrayOf(LDValue.of(2), LDValue.of("three"))); + + assertEquals(LDValue.buildArray().build(), LDValue.arrayOf()); + + assertEquals(LDValue.buildArray().build(), LDValue.arrayOf((LDValue[])null)); + } + @Test public void testTypeConversions() { testTypeConversion(LDValue.Convert.Boolean, new Boolean[] { true, false }, false, LDValue.of(true), LDValue.of(false)); diff --git a/src/test/java/com/launchdarkly/sdk/LDValueTest.java b/src/test/java/com/launchdarkly/sdk/LDValueTest.java index af49bd9..39bdac9 100644 --- a/src/test/java/com/launchdarkly/sdk/LDValueTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueTest.java @@ -1,9 +1,12 @@ package com.launchdarkly.sdk; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.MalformedJsonException; import com.launchdarkly.sdk.json.SerializationException; import org.junit.Test; +import java.io.StringReader; import java.util.List; import static java.util.Arrays.asList; @@ -258,4 +261,28 @@ public void parseThrowsRuntimeExceptionForMalformedJson() { assertThat(e.getCause(), instanceOf(SerializationException.class)); } } + + @Test + public void testLowLevelTypeAdapter() throws Exception { + // This test ensures full test coverage of LDValueTypeAdapter code paths that might not + // be exercised indirectly by other tests. + verifyTypeAdapterRead("null", LDValue.ofNull()); + verifyTypeAdapterRead("true", LDValue.of(true)); + verifyTypeAdapterRead("1", LDValue.of(1)); + verifyTypeAdapterRead("\"x\"", LDValue.of("x")); + verifyTypeAdapterRead("[1,2]", LDValue.buildArray().add(1).add(2).build()); + verifyTypeAdapterRead("{\"a\":1}", LDValue.buildObject().put("a", 1).build()); + + try (JsonReader r = new JsonReader(new StringReader("]"))) { + try { + LDValueTypeAdapter.INSTANCE.read(r); + } catch (MalformedJsonException e) {} + } + } + + private static void verifyTypeAdapterRead(String jsonString, LDValue expectedValue) throws Exception { + try (JsonReader r = new JsonReader(new StringReader(jsonString))) { + assertEquals(expectedValue, LDValueTypeAdapter.INSTANCE.read(r)); + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java index 93634c7..63b75eb 100644 --- a/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/EvaluationDetailJsonSerializationTest.java @@ -11,6 +11,7 @@ import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.CLIENT_NOT_READY; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializationDeserializesTo; @SuppressWarnings("javadoc") public class EvaluationDetailJsonSerializationTest extends BaseTest { @@ -24,12 +25,22 @@ public void detailJsonSerializations() throws Exception { EvaluationDetail.fromValue(LDValue.of("x"), NO_VARIATION, EvaluationReason.error(CLIENT_NOT_READY)), "{\"value\":\"x\",\"reason\":{\"kind\":\"ERROR\",\"errorKind\":\"CLIENT_NOT_READY\"}}"); - verifySerialize(EvaluationDetail.fromValue((String)null, 1, EvaluationReason.off()), - "{\"variationIndex\":1,\"reason\":{\"kind\":\"OFF\"}}"); // Gson will omit the "value: null" + // EvaluationDetail that contains some type other than LDValue that Gson knows how to serialize + verifySerialize(EvaluationDetail.fromValue("x", 1, EvaluationReason.off()), + "{\"value\":\"x\",\"variationIndex\":1,\"reason\":{\"kind\":\"OFF\"}}"); + + // If the value is a null reference (rather than LDValue.ofNull()) it should serialize as a null + // rather than throwing a NPE + verifySerializationDeserializesTo(EvaluationDetail.fromValue((LDValue)null, 1, EvaluationReason.off()), + EvaluationDetail.fromValue(LDValue.ofNull(), 1, EvaluationReason.off())); // Due to how generic types work in Gson, simply calling Gson.fromJson> will *not* // use any custom deserialization for type T; it will behave as if T were LDValue. However, it should // correctly pick up the type signature if you deserialize an object that contains such a value. That // scenario is covered in ReflectiveFrameworksTest. + + // Unknown properties are ignored + JsonTestHelpers.verifyDeserialize(EvaluationDetail.fromValue(LDValue.of("x"), 1, EvaluationReason.off()), + "{\"pleaseIgnoreThis\":[1,2,3],\"value\":\"x\",\"variationIndex\":1,\"reason\":{\"kind\":\"OFF\"}}"); } } diff --git a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java index 96fa087..f80546e 100644 --- a/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java +++ b/src/test/java/com/launchdarkly/sdk/json/JsonTestHelpers.java @@ -6,6 +6,7 @@ import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import com.google.gson.JsonParseException; +import com.google.gson.JsonSyntaxException; import com.launchdarkly.sdk.BaseTest; import com.launchdarkly.sdk.LDValue; @@ -14,6 +15,8 @@ @SuppressWarnings("javadoc") public abstract class JsonTestHelpers extends BaseTest { + static final Gson gson = new Gson(); + // Note that when we verify the behavior of Gson with LDGson in this project's unit tests, that // is not an adequate test for whether the adapters will work in the Java SDK where there is the // additional issue of Gson types being shaded. The Java SDK project must do its own basic tests @@ -45,6 +48,17 @@ public static void verifySerialize(T instance, Stri assertJsonEquals(expectedJsonString, configureJacksonMapper().writeValueAsString(instance)); } + public static void verifySerializationDeserializesTo(T instance, T resultInstance) throws Exception { + assertEquals(resultInstance, + JsonSerialization.deserialize(JsonSerialization.serialize(instance), resultInstance.getClass())); + + assertEquals(resultInstance, + configureGson().fromJson(configureGson().toJson(instance), resultInstance.getClass())); + + assertEquals(resultInstance, + configureJacksonMapper().readValue(configureJacksonMapper().writeValueAsString(instance), resultInstance.getClass())); + } + @SuppressWarnings("unchecked") public static void verifyDeserialize(T instance, String expectedJsonString) throws Exception { // Special handling here because in real life you wouldn't be trying to deserialize something as for @@ -81,10 +95,32 @@ public static void verifyDeserializeInvalidJson(Cla } public static void assertJsonEquals(String expectedJsonString, String actualJsonString) { - assertEquals(parseElement(expectedJsonString), parseElement(actualJsonString)); + try { + JsonElement actualParsed = parseElement(actualJsonString); + assertEquals(parseElement(expectedJsonString), actualParsed); + } catch (JsonSyntaxException e) { + fail("expected JSON: " + expectedJsonString + ", but got malformed JSON: " + actualJsonString); + } } public static JsonElement parseElement(String jsonString) { - return JsonSerialization.gson.fromJson(jsonString, JsonElement.class); + return gson.fromJson(jsonString, JsonElement.class); + } + + public static LDValue basicArrayValue() { + return LDValue.buildArray().add(2).add("x").build(); + } + + public static LDValue basicObjectValue() { + return LDValue.buildObject().put("x", 2).build(); + } + + public static LDValue nestedArrayValue() { + return LDValue.buildArray().add(3).add(basicArrayValue()).add(4).add(basicObjectValue()).add(5).build(); + } + + public static LDValue nestedObjectValue() { + return LDValue.buildObject().put("a", 1).put("b", basicArrayValue()).put("c", 2) + .put("d", basicObjectValue()).put("e", 5).build(); } } diff --git a/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java new file mode 100644 index 0000000..362334f --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/LDGsonTest.java @@ -0,0 +1,166 @@ +package com.launchdarkly.sdk.json; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSyntaxException; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; + +import org.junit.Test; + +import java.io.StringReader; +import java.io.StringWriter; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class LDGsonTest { + // Note that these unit tests don't fully prove that our Gson integration works as intended + // in SDK distributions that shade the Gson classes, because the tests for this project are + // run on the unmodified code with real Gson classes in the classpath. SDKs that use shading + // must implement their own unit tests, run against SDK code that has had shading applied, + // to verify that these methods still work in that environment. However, those tests do not + // need to be as detailed as these in terms of covering all of the various JSON types; if + // the methods work for anything, they should work for everything, since the issue there is + // just whether the correct Gson package names are being used. + + @Test + public void valueToJsonElement() { + verifyValueSerialization(LDValue.ofNull()); + verifyValueSerialization(LDValue.of(true)); + verifyValueSerialization(LDValue.of(false)); + verifyValueSerialization(LDValue.of("x")); + verifyValueSerialization(LDValue.of("say \"hello\"")); + verifyValueSerialization(LDValue.of(2)); + verifyValueSerialization(LDValue.of(2.5f)); + verifyValueSerialization(JsonTestHelpers.nestedArrayValue()); + verifyValueSerialization(JsonTestHelpers.nestedObjectValue()); + assertEquals(JsonNull.INSTANCE, LDGson.valueToJsonElement(null)); + } + + @Test + public void valueMapToJsonElementMap() { + Map m1 = new HashMap<>(); + m1.put("a", LDValue.of(true)); + m1.put("b", LDValue.of(1)); + String js1 = JsonTestHelpers.gson.toJson(m1); + + Map m2 = LDGson.valueMapToJsonElementMap(m1); + String js2 = JsonTestHelpers.gson.toJson(m2); + JsonTestHelpers.assertJsonEquals(js1, js2); + } + + @Test + public void complexObjectToJsonTree() { + LDUser user = new LDUser.Builder("userkey").name("name") + .custom("attr1", LDValue.ofNull()) + .custom("atrt2", LDValue.of(true)) + .custom("attr3", LDValue.of(false)) + .custom("attr4", LDValue.of(0)) + .custom("attr5", LDValue.of(1)) + .custom("attr6", LDValue.of("")) + .custom("attr7", LDValue.of("x")) + .custom("attr8", JsonTestHelpers.nestedArrayValue()) + .custom("attr9", JsonTestHelpers.nestedObjectValue()) + .build(); + JsonElement j = JsonTestHelpers.configureGson().toJsonTree(user); + String js = JsonTestHelpers.gson.toJson(j); + assertEquals(LDValue.parse(JsonSerialization.serialize(user)), LDValue.parse(js)); + } + + @Test + public void testInternalReaderAdapter() throws Exception { + // This and testInternalWriterAdapter verify that all of our reader/writer delegation + // methods work as expected, regardless of whether or not they are exercised indirectly + // by our other unit tests. + String json = "[null,false,true,1.5,2,3,\"x\",{\"a\":false}]"; + try (StringReader sr = new StringReader(json)) { + try (JsonReader jr0 = new JsonReader(sr)) { + try (JsonReader jr = new LDGson.DelegatingJsonReaderAdapter(jr0)) { + jr.beginArray(); + assertEquals(true, jr.hasNext()); + jr.nextNull(); + assertEquals(JsonToken.BOOLEAN, jr.peek()); + jr.skipValue(); + assertEquals(true, jr.nextBoolean()); + assertEquals(1.5d, jr.nextDouble(), 0); + assertEquals(2, jr.nextInt()); + assertEquals(3, jr.nextLong()); + assertEquals("x", jr.nextString()); + jr.beginObject(); + assertEquals(true, jr.hasNext()); + assertEquals("a", jr.nextName()); + assertEquals(false, jr.nextBoolean()); + assertEquals(false, jr.hasNext()); + jr.endObject(); + assertEquals(false, jr.hasNext()); + jr.endArray(); + assertEquals(JsonToken.END_DOCUMENT, jr.peek()); + } + } + } + } + + @Test + public void testInternalWriterAdapter() throws Exception { + try (StringWriter sw = new StringWriter()) { + try (JsonWriter jw0 = new JsonWriter(sw)) { + try (JsonWriter jw = new LDGson.DelegatingJsonWriterAdapter(jw0)) { + jw.beginArray(); + jw.nullValue(); + jw.value(true); + jw.value(Boolean.valueOf(true)); + jw.value((Boolean)null); + jw.value((double)1); + jw.value((long)2); + jw.value(Float.valueOf(3)); + jw.value((Float)null); + jw.value("x"); + jw.beginObject(); + jw.name("a"); + jw.value(false); + jw.endObject(); + jw.jsonValue("123"); + jw.endArray(); + jw.flush(); + } + } + String expected = "[null,true,true,null,1,2,3,null,\"x\",{\"a\":false},123]"; + JsonTestHelpers.assertJsonEquals(expected, sw.toString()); + } + } + + @Test(expected=JsonSyntaxException.class) + public void parseExceptionIsThrownForMalformedJson() throws Exception { + JsonTestHelpers.configureGson().fromJson("[1:,2]", LDValue.class); + } + + @Test + public void parseExceptionIsThrownForIllegalValue() throws Exception { + try { + JsonTestHelpers.configureGson().fromJson("{\"kind\":\"NOTGOOD\"}", EvaluationReason.class); + fail("expected exception"); + } catch (JsonParseException e) { + assertTrue(e.getMessage(), e.getMessage().contains("unsupported value \"NOTGOOD\"")); + } + } + + static void verifyValueSerialization(LDValue value) { + JsonElement j1 = LDGson.valueToJsonElement(value); + String js1 = JsonTestHelpers.gson.toJson(j1); + JsonTestHelpers.assertJsonEquals(value.toJsonString(), js1); + + JsonElement j2 = JsonTestHelpers.configureGson().toJsonTree(value); + String js2 = JsonTestHelpers.gson.toJson(j2); + JsonTestHelpers.assertJsonEquals(value.toJsonString(), js2); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/json/LDJacksonTest.java b/src/test/java/com/launchdarkly/sdk/json/LDJacksonTest.java new file mode 100644 index 0000000..415d8c5 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/json/LDJacksonTest.java @@ -0,0 +1,97 @@ +package com.launchdarkly.sdk.json; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; + +import org.junit.Test; + +import java.io.StringWriter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class LDJacksonTest { + @Test + public void testInternalReaderAdapter() throws Exception { + // This and testInternalWriterAdapter verify that all of our reader/writer delegation + // methods work as expected, regardless of whether or not they are exercised indirectly + // by our other unit tests. + String json = "[null,false,true,1.5,2,3,\"x\",{\"a\":false}]"; + JsonParser p = new JsonFactory().createParser(json); + p.nextToken(); + try (JsonReader jr = new LDJackson.GsonReaderToJacksonParserAdapter(p)) { + jr.beginArray(); + assertEquals(true, jr.hasNext()); + jr.nextNull(); + assertEquals(JsonToken.BOOLEAN, jr.peek()); + jr.skipValue(); + assertEquals(true, jr.nextBoolean()); + assertEquals(1.5d, jr.nextDouble(), 0); + assertEquals(2, jr.nextInt()); + assertEquals(3, jr.nextLong()); + assertEquals("x", jr.nextString()); + jr.beginObject(); + assertEquals(true, jr.hasNext()); + assertEquals("a", jr.nextName()); + assertEquals(false, jr.nextBoolean()); + assertEquals(false, jr.hasNext()); + jr.endObject(); + assertEquals(false, jr.hasNext()); + jr.endArray(); + assertEquals(JsonToken.END_DOCUMENT, jr.peek()); + } + } + + @Test + public void testInternalWriterAdapter() throws Exception { + try (StringWriter sw = new StringWriter()) { + JsonGenerator gen = new JsonFactory().createGenerator(sw); + try (JsonWriter jw = new LDJackson.GsonWriterToJacksonGeneratorAdapter(gen)) { + jw.beginArray(); + jw.nullValue(); + jw.value(true); + jw.value(Boolean.valueOf(true)); + jw.value((Boolean)null); + jw.value((double)1); + jw.value((long)2); + jw.value(Float.valueOf(3)); + jw.value((Float)null); + jw.value("x"); + jw.beginObject(); + jw.name("a"); + jw.value(false); + jw.endObject(); + jw.jsonValue("123"); + jw.endArray(); + jw.flush(); + } + gen.flush(); + String expected = "[null,true,true,null,1,2,3,null,\"x\",{\"a\":false},123]"; + JsonTestHelpers.assertJsonEquals(expected, sw.toString()); + } + } + + @Test(expected=JsonParseException.class) + public void parseExceptionIsThrownForMalformedJson() throws Exception { + JsonTestHelpers.configureJacksonMapper().readValue("[1:,2]", LDValue.class); + } + + @Test + public void parseExceptionIsThrownForIllegalValue() throws Exception { + try { + JsonTestHelpers.configureJacksonMapper().readValue("{\"kind\":\"NOTGOOD\"}", EvaluationReason.class); + fail("expected exception"); + } catch (JsonParseException e) { + assertTrue(e.getMessage(), e.getMessage().contains("unsupported value \"NOTGOOD\"")); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java index 18c7334..95370da 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDUserJsonSerializationTest.java @@ -10,6 +10,7 @@ import static com.launchdarkly.sdk.TestHelpers.builtInAttributes; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifyDeserializeInvalidJson; +import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerialize; import static com.launchdarkly.sdk.json.JsonTestHelpers.verifySerializeAndDeserialize; @SuppressWarnings("javadoc") @@ -21,6 +22,8 @@ public void minimalJsonEncoding() throws Exception { verifyDeserializeInvalidJson(LDUser.class, "3"); verifyDeserializeInvalidJson(LDUser.class, "{\"key\":\"userkey\",\"name\":3"); + + verifySerialize((LDUser)null, "null"); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java index 55e5511..04cd551 100644 --- a/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/json/LDValueJsonSerializationTest.java @@ -16,6 +16,8 @@ public class LDValueJsonSerializationTest extends BaseTest { @Test public void jsonEncodingForNull() throws Exception { verifySerialize(LDValue.ofNull(), "null"); + verifySerialize((LDValue)null, "null"); + assertEquals(LDValue.ofNull(), LDValue.parse("null")); } @Test @@ -27,8 +29,8 @@ public void jsonEncodingForNonNullValues() throws Exception { verifyValueSerialization(LDValue.of(2), "2"); verifyValueSerialization(LDValue.of(2.5f), "2.5"); verifyValueSerialization(LDValue.of(2.5d), "2.5"); - verifyValueSerialization(LDValue.buildArray().add(2).add("x").build(), "[2,\"x\"]"); - verifyValueSerialization(LDValue.buildObject().put("x", 2).build(), "{\"x\":2}"); + verifyValueSerialization(JsonTestHelpers.basicArrayValue(), "[2,\"x\"]"); + verifyValueSerialization(JsonTestHelpers.basicObjectValue(), "{\"x\":2}"); verifyDeserializeInvalidJson(LDValue.class, "]"); }