Repository: firebase/firebase-admin-java
Pull request #500 (2020-12-07 21:08:01)

Add toJSON and fromJSON to Remote Config Template
- Add `toJSON()` and `fromJSON()` to `Template`
- Add unit tests

Related to: #446 

lahirumaramba: > LGTM with a suggestion.

Thanks for the review!! Made the suggested updates.

Patch:
diff --git a/src/main/java/com/google/firebase/remoteconfig/Condition.java b/src/main/java/com/google/firebase/remoteconfig/Condition.java
index c10498c..6ccde59 100644
--- a/src/main/java/com/google/firebase/remoteconfig/Condition.java
+++ b/src/main/java/com/google/firebase/remoteconfig/Condition.java
@@ -69,9 +69,7 @@ public final class Condition {
     checkNotNull(conditionResponse);
     this.name = conditionResponse.getName();
     this.expression = conditionResponse.getExpression();
-    if (conditionResponse.getTagColor() == null) {
-      this.tagColor = TagColor.UNSPECIFIED;
-    } else {
+    if (!Strings.isNullOrEmpty(conditionResponse.getTagColor())) {
       this.tagColor = TagColor.valueOf(conditionResponse.getTagColor());
     }
   }
@@ -168,4 +166,9 @@ public final class Condition {
     return Objects.equals(name, condition.name)
             && Objects.equals(expression, condition.expression) && tagColor == condition.tagColor;
   }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(name, expression, tagColor);
+  }
 }
diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java
index aff5439..7425673 100644
--- a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java
+++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java
@@ -121,7 +121,7 @@ final class FirebaseRemoteConfigClientImpl implements FirebaseRemoteConfigClient
                                   boolean forcePublish) throws FirebaseRemoteConfigException {
     checkArgument(template != null, "Template must not be null.");
     HttpRequestInfo request = HttpRequestInfo.buildRequest("PUT", remoteConfigUrl,
-            new JsonHttpContent(jsonFactory, template.toTemplateResponse()))
+            new JsonHttpContent(jsonFactory, template.toTemplateResponse(false)))
             .addAllHeaders(COMMON_HEADERS)
             .addHeader("If-Match", forcePublish ? "*" : template.getETag());
     if (validateOnly) {
diff --git a/src/main/java/com/google/firebase/remoteconfig/RemoteConfigUtil.java b/src/main/java/com/google/firebase/remoteconfig/RemoteConfigUtil.java
index 13a4907..4011af8 100644
--- a/src/main/java/com/google/firebase/remoteconfig/RemoteConfigUtil.java
+++ b/src/main/java/com/google/firebase/remoteconfig/RemoteConfigUtil.java
@@ -20,20 +20,74 @@ import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.common.base.Strings;
 
+import java.text.ParseException;
 import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.TimeZone;
 
 final class RemoteConfigUtil {
 
+  // SimpleDateFormat cannot handle fractional seconds in timestamps
+  // (example: "2014-10-02T15:01:23.045123456Z"). Therefore, we strip fractional seconds
+  // from the date string (example: "2014-10-02T15:01:23") when parsing Zulu timestamp strings.
+  // The backend API expects timestamps in Zulu format with fractional seconds. To generate correct
+  // timestamps in payloads we use ".SSS000000'Z'" suffix.
+  // Hence, two Zulu date patterns are used below.
+  private static final String ZULU_DATE_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS000000'Z'";
+  private static final String ZULU_DATE_NO_FRAC_SECS_PATTERN = "yyyy-MM-dd'T'HH:mm:ss";
+  private static final String UTC_DATE_PATTERN = "EEE, dd MMM yyyy HH:mm:ss z";
+  private static final TimeZone UTC_TIME_ZONE = TimeZone.getTimeZone("UTC");
+
   static boolean isValidVersionNumber(String versionNumber) {
     return !Strings.isNullOrEmpty(versionNumber) && versionNumber.matches("^\\d+$");
   }
 
+  static long convertToMilliseconds(String dateString) throws ParseException {
+    try {
+      return convertFromUtcZuluFormat(dateString);
+    } catch (ParseException e) {
+      return convertFromUtcDateFormat(dateString);
+    }
+  }
+
   static String convertToUtcZuluFormat(long millis) {
+    // sample output date string: 2020-11-12T22:12:02.000000000Z
     checkArgument(millis >= 0, "Milliseconds duration must not be negative");
-    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS000000'Z'");
-    dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+    SimpleDateFormat dateFormat = new SimpleDateFormat(ZULU_DATE_PATTERN);
+    dateFormat.setTimeZone(UTC_TIME_ZONE);
     return dateFormat.format(new Date(millis));
   }
+
+  static String convertToUtcDateFormat(long millis) {
+    // sample output date string: Tue, 08 Dec 2020 15:49:51 GMT
+    checkArgument(millis >= 0, "Milliseconds duration must not be negative");
+    SimpleDateFormat dateFormat = new SimpleDateFormat(UTC_DATE_PATTERN);
+    dateFormat.setTimeZone(UTC_TIME_ZONE);
+    return dateFormat.format(new Date(millis));
+  }
+
+  static long convertFromUtcZuluFormat(String dateString) throws ParseException {
+    checkArgument(!Strings.isNullOrEmpty(dateString), "Date string must not be null or empty");
+    // Input timestamp is in RFC3339 UTC "Zulu" format, accurate to
+    // nanoseconds (up to 9 fractional seconds digits).
+    // SimpleDateFormat cannot handle fractional seconds, therefore we strip fractional seconds
+    // from the input date string before parsing.
+    // example: input -> "2014-10-02T15:01:23.045123456Z"
+    // formatted -> "2014-10-02T15:01:23"
+    int indexOfPeriod = dateString.indexOf(".");
+    if (indexOfPeriod != -1) {
+      dateString = dateString.substring(0, indexOfPeriod);
+    }
+    SimpleDateFormat dateFormat = new SimpleDateFormat(ZULU_DATE_NO_FRAC_SECS_PATTERN);
+    dateFormat.setTimeZone(UTC_TIME_ZONE);
+    return dateFormat.parse(dateString).getTime();
+  }
+
+  static long convertFromUtcDateFormat(String dateString) throws ParseException {
+    // sample input date string: Tue, 08 Dec 2020 15:49:51 GMT
+    checkArgument(!Strings.isNullOrEmpty(dateString), "Date string must not be null or empty");
+    SimpleDateFormat dateFormat = new SimpleDateFormat(UTC_DATE_PATTERN);
+    dateFormat.setTimeZone(UTC_TIME_ZONE);
+    return dateFormat.parse(dateString).getTime();
+  }
 }
diff --git a/src/main/java/com/google/firebase/remoteconfig/Template.java b/src/main/java/com/google/firebase/remoteconfig/Template.java
index c6ffcb4..24bd68a 100644
--- a/src/main/java/com/google/firebase/remoteconfig/Template.java
+++ b/src/main/java/com/google/firebase/remoteconfig/Template.java
@@ -16,11 +16,17 @@
 
 package com.google.firebase.remoteconfig;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 
+import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.json.JsonFactory;
+import com.google.common.base.Strings;
+import com.google.firebase.ErrorCode;
 import com.google.firebase.internal.NonNull;
 import com.google.firebase.remoteconfig.internal.TemplateResponse;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -40,11 +46,18 @@ public final class Template {
 
   /**
    * Creates a new {@link Template}.
+   *
+   * @param etag The ETag of this template.
    */
-  public Template() {
-    parameters = new HashMap<>();
-    conditions = new ArrayList<>();
-    parameterGroups = new HashMap<>();
+  public Template(String etag) {
+    this.parameters = new HashMap<>();
+    this.conditions = new ArrayList<>();
+    this.parameterGroups = new HashMap<>();
+    this.etag = etag;
+  }
+
+  Template() {
+    this((String) null);
   }
 
   Template(@NonNull TemplateResponse templateResponse) {
@@ -73,6 +86,29 @@ public final class Template {
     if (templateResponse.getVersion() != null) {
       this.version = new Version(templateResponse.getVersion());
     }
+    this.etag = templateResponse.getEtag();
+  }
+
+  /**
+   * Creates and returns a new Remote Config template from a JSON string.
+   * Input JSON string must contain an {@code etag} property to create a valid template.
+   *
+   * @param json A non-null JSON string to populate a Remote Config template.
+   * @return A new {@link Template} instance.
+   * @throws FirebaseRemoteConfigException If the input JSON string is not parsable.
+   */
+  public static Template fromJSON(@NonNull String json) throws FirebaseRemoteConfigException {
+    checkArgument(!Strings.isNullOrEmpty(json), "JSON String must not be null or empty.");
+    // using the default json factory as no rpc calls are made here
+    JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
+    try {
+      TemplateResponse templateResponse = jsonFactory.createJsonParser(json)
+              .parseAndClose(TemplateResponse.class);
+      return new Template(templateResponse);
+    } catch (IOException e) {
+      throw new FirebaseRemoteConfigException(ErrorCode.INVALID_ARGUMENT,
+              "Unable to parse JSON string.");
+    }
   }
 
   /**
@@ -177,12 +213,26 @@ public final class Template {
     return this;
   }
 
+  /**
+   * Gets the JSON-serializable representation of this template.
+   *
+   * @return A JSON-serializable representation of this {@link Template} instance.
+   */
+  public String toJSON() {
+    JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
+    try {
+      return jsonFactory.toString(this.toTemplateResponse(true));
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
   Template setETag(String etag) {
     this.etag = etag;
     return this;
   }
 
-  TemplateResponse toTemplateResponse() {
+  TemplateResponse toTemplateResponse(boolean includeAll) {
     Map<String, TemplateResponse.ParameterResponse> parameterResponses = new HashMap<>();
     for (Map.Entry<String, Parameter> entry : this.parameters.entrySet()) {
       parameterResponses.put(entry.getKey(), entry.getValue().toParameterResponse());
@@ -196,12 +246,16 @@ public final class Template {
       parameterGroupResponse.put(entry.getKey(), entry.getValue().toParameterGroupResponse());
     }
     TemplateResponse.VersionResponse versionResponse = (this.version == null) ? null
-            : this.version.toVersionResponse();
-    return new TemplateResponse()
+            : this.version.toVersionResponse(includeAll);
+    TemplateResponse templateResponse = new TemplateResponse()
             .setParameters(parameterResponses)
             .setConditions(conditionResponses)
             .setParameterGroups(parameterGroupResponse)
             .setVersion(versionResponse);
+    if (includeAll) {
+      return templateResponse.setEtag(this.etag);
+    }
+    return templateResponse;
   }
 
   @Override
diff --git a/src/main/java/com/google/firebase/remoteconfig/User.java b/src/main/java/com/google/firebase/remoteconfig/User.java
index ae21328..264fe8b 100644
--- a/src/main/java/com/google/firebase/remoteconfig/User.java
+++ b/src/main/java/com/google/firebase/remoteconfig/User.java
@@ -88,4 +88,11 @@ public class User {
   public int hashCode() {
     return Objects.hash(email, name, imageUrl);
   }
+
+  UserResponse toUserResponse() {
+    return new UserResponse()
+            .setEmail(this.email)
+            .setImageUrl(this.imageUrl)
+            .setName(this.name);
+  }
 }
diff --git a/src/main/java/com/google/firebase/remoteconfig/Version.java b/src/main/java/com/google/firebase/remoteconfig/Version.java
index e3c1824..5aa6c94 100644
--- a/src/main/java/com/google/firebase/remoteconfig/Version.java
+++ b/src/main/java/com/google/firebase/remoteconfig/Version.java
@@ -25,11 +25,7 @@ import com.google.firebase.remoteconfig.internal.TemplateResponse;
 import com.google.firebase.remoteconfig.internal.TemplateResponse.VersionResponse;
 
 import java.text.ParseException;
-import java.text.SimpleDateFormat;
 import java.util.Objects;
-import java.util.TimeZone;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
 
 /**
  * Represents a Remote Config template version.
@@ -64,18 +60,8 @@ public final class Version {
     this.versionNumber = versionResponse.getVersionNumber();
 
     if (!Strings.isNullOrEmpty(versionResponse.getUpdateTime())) {
-      // Update Time is a timestamp in RFC3339 UTC "Zulu" format, accurate to nanoseconds.
-      // example: "2014-10-02T15:01:23.045123456Z"
-      // SimpleDateFormat cannot handle nanoseconds, therefore we strip nanoseconds from the string.
-      String updateTime = versionResponse.getUpdateTime();
-      int indexOfPeriod = updateTime.indexOf(".");
-      if (indexOfPeriod != -1) {
-        updateTime = updateTime.substring(0, indexOfPeriod);
-      }
-      SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
-      dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
       try {
-        this.updateTime = dateFormat.parse(updateTime).getTime();
+        this.updateTime = RemoteConfigUtil.convertToMilliseconds(versionResponse.getUpdateTime());
       } catch (ParseException e) {
         throw new IllegalStateException("Unable to parse update time.", e);
       }
@@ -196,9 +182,19 @@ public final class Version {
     return this;
   }
 
-  VersionResponse toVersionResponse() {
-    return new VersionResponse()
-            .setDescription(this.description);
+  VersionResponse toVersionResponse(boolean includeAll) {
+    VersionResponse versionResponse = new VersionResponse().setDescription(this.description);
+    if (includeAll) {
+      versionResponse.setUpdateTime(this.updateTime > 0L
+              ? RemoteConfigUtil.convertToUtcDateFormat(this.updateTime) : null)
+              .setLegacy(this.legacy)
+              .setRollbackSource(this.rollbackSource)
+              .setUpdateOrigin(this.updateOrigin)
+              .setUpdateType(this.updateType)
+              .setUpdateUser((this.updateUser == null) ? null : this.updateUser.toUserResponse())
+              .setVersionNumber(this.versionNumber);
+    }
+    return versionResponse;
   }
 
   @Override
diff --git a/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java b/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java
index b89abf1..09e71ef 100644
--- a/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java
+++ b/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java
@@ -39,6 +39,11 @@ public final class TemplateResponse {
   @Key("version")
   private VersionResponse version;
 
+  // For local JSON serialization and deserialization purposes only.
+  // ETag in response type is never set by the HTTP response.
+  @Key("etag")
+  private String etag;
+
   public Map<String, ParameterResponse> getParameters() {
     return parameters;
   }
@@ -55,6 +60,10 @@ public final class TemplateResponse {
     return version;
   }
 
+  public String getEtag() {
+    return etag;
+  }
+
   public TemplateResponse setParameters(
           Map<String, ParameterResponse> parameters) {
     this.parameters = parameters;
@@ -78,6 +87,11 @@ public final class TemplateResponse {
     return this;
   }
 
+  public TemplateResponse setEtag(String etag) {
+    this.etag = etag;
+    return this;
+  }
+
   /**
    * The Data Transfer Object for parsing Remote Config parameter responses from the
    * Remote Config service.


Test patch:
diff --git a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
index 690abcb..9be4185 100644
--- a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
+++ b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
@@ -105,7 +105,6 @@ public class FirebaseRemoteConfigClientImplTest {
                   .setTagColor(TagColor.INDIGO),
           new Condition("android_en",
                   "device.os == 'android' && device.country in ['us', 'uk']")
-                  .setTagColor(TagColor.UNSPECIFIED)
   );
 
   private static final Version EXPECTED_VERSION = new Version(new TemplateResponse.VersionResponse()
diff --git a/src/test/java/com/google/firebase/remoteconfig/TemplateTest.java b/src/test/java/com/google/firebase/remoteconfig/TemplateTest.java
index 57f5a21..ad4d48f 100644
--- a/src/test/java/com/google/firebase/remoteconfig/TemplateTest.java
+++ b/src/test/java/com/google/firebase/remoteconfig/TemplateTest.java
@@ -24,6 +24,7 @@ import static org.junit.Assert.assertTrue;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.firebase.remoteconfig.internal.TemplateResponse;
 
 import java.util.List;
 import java.util.Map;
@@ -91,9 +92,22 @@ public class TemplateTest {
     assertNull(template.getETag());
   }
 
+  @Test
+  public void testConstructorWithETag() {
+    Template template = new Template("etag-01-324324");
+
+    assertNotNull(template.getParameters());
+    assertNotNull(template.getConditions());
+    assertNotNull(template.getParameterGroups());
+    assertTrue(template.getParameters().isEmpty());
+    assertTrue(template.getConditions().isEmpty());
+    assertTrue(template.getParameterGroups().isEmpty());
+    assertEquals("etag-01-324324", template.getETag());
+  }
+
   @Test(expected = NullPointerException.class)
   public void testConstructorWithNullTemplateResponse() {
-    new Template(null);
+    new Template((TemplateResponse) null);
   }
 
   @Test(expected = NullPointerException.class)
@@ -133,8 +147,7 @@ public class TemplateTest {
 
     assertEquals(TEMPLATE_WITH_CONDITIONS_PARAMETERS_GROUPS, templateThree);
 
-    final Template templateFour = new Template()
-            .setETag("etag-123456789097-20");
+    final Template templateFour = new Template("etag-123456789097-20");
 
     assertEquals(TEMPLATE_WITH_ETAG, templateFour);
 
@@ -153,4 +166,181 @@ public class TemplateTest {
     assertNotEquals(templateThree, templateFive);
     assertNotEquals(templateFour, templateFive);
   }
+
+  @Test(expected = FirebaseRemoteConfigException.class)
+  public void testFromJSONWithInvalidString() throws FirebaseRemoteConfigException {
+    Template.fromJSON("abc");
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testFromJSONWithEmptyString() throws FirebaseRemoteConfigException {
+    Template.fromJSON("");
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testFromJSONWithNullString() throws FirebaseRemoteConfigException {
+    Template.fromJSON(null);
+  }
+
+  @Test
+  public void testFromJSONWithEmptyTemplateString() throws FirebaseRemoteConfigException {
+    Template template = Template.fromJSON("{}");
+
+    assertNotNull(template.getParameters());
+    assertNotNull(template.getConditions());
+    assertNotNull(template.getParameterGroups());
+    assertTrue(template.getParameters().isEmpty());
+    assertTrue(template.getConditions().isEmpty());
+    assertTrue(template.getParameterGroups().isEmpty());
+    assertNull(template.getETag());
+  }
+
+  @Test
+  public void testFromJSONWithNonEmptyTemplateString() throws FirebaseRemoteConfigException {
+    Template template = Template.fromJSON("{"
+            + "  \"etag\": \"etag-001234\","
+            + "  \"conditions\": ["
+            + "    {"
+            + "      \"name\": \"ios_en\","
+            + "      \"expression\": \"device.os == 'ios' && device.country in ['us', 'uk']\","
+            + "      \"tagColor\": \"INDIGO\""
+            + "    },"
+            + "    {"
+            + "      \"name\": \"android_en\","
+            + "      \"expression\": \"device.os == 'android' && device.country in ['us', 'uk']\""
+            + "    }"
+            + "  ]"
+            + "}");
+
+    assertNotNull(template.getParameters());
+    assertNotNull(template.getConditions());
+    assertNotNull(template.getParameterGroups());
+    assertTrue(template.getParameters().isEmpty());
+    assertEquals(2, template.getConditions().size());
+    assertEquals("ios_en", template.getConditions().get(0).getName());
+    assertEquals("device.os == 'ios' && device.country in ['us', 'uk']",
+            template.getConditions().get(0).getExpression());
+    assertEquals(TagColor.INDIGO, template.getConditions().get(0).getTagColor());
+    assertEquals("android_en", template.getConditions().get(1).getName());
+    assertEquals("device.os == 'android' && device.country in ['us', 'uk']",
+            template.getConditions().get(1).getExpression());
+    assertNull(template.getConditions().get(1).getTagColor());
+    assertTrue(template.getParameterGroups().isEmpty());
+    assertEquals("etag-001234", template.getETag());
+  }
+
+  @Test
+  public void testFromJSONWithVersion() throws FirebaseRemoteConfigException {
+    final Version expectedVersion = new Version(new TemplateResponse.VersionResponse()
+            .setDescription("template version")
+            .setUpdateTime("2020-12-08T15:49:51.887878Z")
+            .setUpdateUser(new TemplateResponse.UserResponse().setEmail("user@user.com"))
+            .setLegacy(false)
+            .setUpdateType("INCREMENTAL_UPDATE")
+            .setRollbackSource("26")
+            .setVersionNumber("34")
+            .setUpdateOrigin("ADMIN_SDK_NODE")
+    );
+    String jsonString = "{\"parameters\":{},\"conditions\":[],\"parameterGroups\":{},"
+            + "\"version\":{\"versionNumber\":\"34\","
+            + "\"updateTime\":\"Tue, 08 Dec 2020 15:49:51 UTC\","
+            + "\"updateOrigin\":\"ADMIN_SDK_NODE\",\"updateType\":\"INCREMENTAL_UPDATE\","
+            + "\"updateUser\":{\"email\":\"user@user.com\"},\"rollbackSource\":\"26\","
+            + "\"legacy\":false,\"description\":\"template version\"}}";
+    Template template = Template.fromJSON(jsonString);
+
+    assertNotNull(template.getParameters());
+    assertNotNull(template.getConditions());
+    assertNotNull(template.getParameterGroups());
+    assertTrue(template.getParameters().isEmpty());
+    assertTrue(template.getConditions().isEmpty());
+    assertTrue(template.getParameterGroups().isEmpty());
+    assertNull(template.getETag());
+    // check version
+    assertEquals(expectedVersion, template.getVersion());
+    // update time should be correctly converted to milliseconds
+    assertEquals(1607442591000L, template.getVersion().getUpdateTime());
+  }
+
+  @Test
+  public void testToJSONWithEmptyTemplate() {
+    String jsonString = new Template().toJSON();
+
+    assertEquals("{\"conditions\":[],"
+            + "\"parameterGroups\":{},\"parameters\":{}}", jsonString);
+  }
+
+  @Test
+  public void testToJSONWithParameterValues() {
+    Template t = new Template();
+    t.getParameters()
+            .put("with_value", new Parameter().setDefaultValue(ParameterValue.of("hello")));
+    t.getParameters()
+            .put("with_inApp", new Parameter().setDefaultValue(ParameterValue.inAppDefault()));
+    String jsonString = t.toJSON();
+
+    assertEquals("{\"conditions\":[],\"parameterGroups\":{},"
+            + "\"parameters\":{\"with_value\":{\"conditionalValues\":{},"
+            + "\"defaultValue\":{\"value\":\"hello\"}},\"with_inApp\":{\"conditionalValues\":{},"
+            + "\"defaultValue\":{\"useInAppDefault\":true}}}}", jsonString);
+  }
+
+  @Test
+  public void testToJSONWithEtag() {
+    String jsonString = new Template("etag-12345").toJSON();
+
+    assertEquals("{\"conditions\":[],\"etag\":\"etag-12345\",\"parameterGroups\":{},"
+            + "\"parameters\":{}}", jsonString);
+  }
+
+  @Test
+  public void testToJSONWithEtagAndConditions() {
+    String jsonString = new Template("etag-0010201")
+            .setConditions(CONDITIONS).toJSON();
+
+    assertEquals("{\"conditions\":[{\"expression\":\"exp ios\",\"name\":\"ios_en\","
+            + "\"tagColor\":\"INDIGO\"},{\"expression\":\"exp android\","
+            + "\"name\":\"android_en\"}],\"etag\":\"etag-0010201\",\"parameterGroups\":{},"
+            + "\"parameters\":{}}", jsonString);
+  }
+
+  @Test
+  public void testToJSONWithVersion() {
+    Version version = new Version(new TemplateResponse.VersionResponse()
+            .setDescription("template version")
+            .setUpdateTime("2020-12-08T15:49:51.887878Z")
+            .setUpdateUser(new TemplateResponse.UserResponse().setEmail("user@user.com"))
+            .setLegacy(false)
+            .setUpdateType("INCREMENTAL_UPDATE")
+            .setRollbackSource("26")
+            .setVersionNumber("34")
+            .setUpdateOrigin("ADMIN_SDK_NODE")
+    );
+    String jsonString = new Template().setVersion(version).toJSON();
+
+    assertEquals("{\"conditions\":[],\"parameterGroups\":{},\"parameters\":{},"
+            + "\"version\":{\"description\":\"template version\",\"legacy\":false,"
+            + "\"rollbackSource\":\"26\",\"updateOrigin\":\"ADMIN_SDK_NODE\","
+            + "\"updateTime\":\"Tue, 08 Dec 2020 15:49:51 UTC\","
+            + "\"updateType\":\"INCREMENTAL_UPDATE\",\"updateUser\":{"
+            + "\"email\":\"user@user.com\"},\"versionNumber\":\"34\"}}", jsonString);
+  }
+
+  @Test
+  public void testToJSONAndFromJSON() throws FirebaseRemoteConfigException {
+    Template originalTemplate = new Template();
+    Template otherTemplate = Template.fromJSON(originalTemplate.toJSON());
+
+    assertEquals(originalTemplate, otherTemplate);
+
+    Version expectedVersion = Version.withDescription("promo version");
+    Template expectedTemplate = new Template("etag-0010201")
+            .setParameters(PARAMETERS)
+            .setConditions(CONDITIONS)
+            .setParameterGroups(PARAMETER_GROUPS)
+            .setVersion(expectedVersion);
+    Template actualTemplate = Template.fromJSON(expectedTemplate.toJSON());
+
+    assertEquals(expectedTemplate, actualTemplate);
+  }
 }
diff --git a/src/test/java/com/google/firebase/remoteconfig/VersionTest.java b/src/test/java/com/google/firebase/remoteconfig/VersionTest.java
index c0a6004..5156296 100644
--- a/src/test/java/com/google/firebase/remoteconfig/VersionTest.java
+++ b/src/test/java/com/google/firebase/remoteconfig/VersionTest.java
@@ -38,6 +38,20 @@ public class VersionTest {
             .setUpdateTime("sunday,26th"));
   }
 
+  @Test
+  public void testConstructorWithValidZuluUpdateTime() {
+    Version version = new Version(new VersionResponse()
+            .setUpdateTime("2020-12-08T15:49:51.887878Z"));
+    assertEquals(1607442591000L, version.getUpdateTime());
+  }
+
+  @Test
+  public void testConstructorWithValidUTCUpdateTime() {
+    Version version = new Version(new VersionResponse()
+            .setUpdateTime("Tue, 08 Dec 2020 15:49:51 GMT"));
+    assertEquals(1607442591000L, version.getUpdateTime());
+  }
+
   @Test
   public void testWithDescription() {
     final Version version = Version.withDescription("version description text");