Repository: firebase/firebase-admin-java
Pull request #497 (2020-11-24 20:21:43)

Add Remote Config rollback operation
- Add `rollback` operations
- Add unit tests

Related to #446 


Patch:
diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java
index c82693d..86eac2e 100644
--- a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java
+++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java
@@ -255,6 +255,82 @@ public final class FirebaseRemoteConfig {
     };
   }
 
+  /**
+   * Rolls back a project's published Remote Config template to the specified version.
+   *
+   * <p>A rollback is equivalent to getting a previously published Remote Config
+   * template and re-publishing it using a force update.
+   *
+   * @param versionNumber The version number of the Remote Config template to roll back to.
+   *                      The specified version number must be lower than the current version
+   *                      number, and not have been deleted due to staleness. Only the last 300
+   *                      versions are stored. All versions that correspond to non-active Remote
+   *                      Config templates (that is, all except the template that is being fetched
+   *                      by clients) are also deleted if they are more than 90 days old.
+   * @return The rolled back {@link Template}.
+   * @throws FirebaseRemoteConfigException If an error occurs while rolling back the template.
+   */
+  public Template rollback(long versionNumber) throws FirebaseRemoteConfigException {
+    String versionNumberString = String.valueOf(versionNumber);
+    return rollbackOp(versionNumberString).call();
+  }
+
+  /**
+   * Rolls back a project's published Remote Config template to the specified version.
+   *
+   * <p>A rollback is equivalent to getting a previously published Remote Config
+   * template and re-publishing it using a force update.
+   *
+   * @param versionNumber The version number of the Remote Config template to roll back to.
+   *                      The specified version number must be lower than the current version
+   *                      number, and not have been deleted due to staleness. Only the last 300
+   *                      versions are stored. All versions that correspond to non-active Remote
+   *                      Config templates (that is, all except the template that is being fetched
+   *                      by clients) are also deleted if they are more than 90 days old.
+   * @return The rolled back {@link Template}.
+   * @throws FirebaseRemoteConfigException If an error occurs while rolling back the template.
+   */
+  public Template rollback(@NonNull String versionNumber) throws FirebaseRemoteConfigException {
+    return rollbackOp(versionNumber).call();
+  }
+
+  /**
+   * Similar to {@link #rollback(long versionNumber)} but performs the operation
+   * asynchronously.
+   *
+   * @param versionNumber The version number of the Remote Config template to roll back to.
+   * @return An {@code ApiFuture} that completes with a {@link Template} once
+   *     the rollback operation is successful.
+   */
+  public ApiFuture<Template> rollbackAsync(long versionNumber) {
+    String versionNumberString = String.valueOf(versionNumber);
+    return rollbackOp(versionNumberString).callAsync(app);
+  }
+
+  /**
+   * Similar to {@link #rollback(String versionNumber)} but performs the operation
+   * asynchronously.
+   *
+   * @param versionNumber The version number of the Remote Config template to roll back to.
+   * @return An {@code ApiFuture} that completes with a {@link Template} once
+   *     the rollback operation is successful.
+   */
+  public ApiFuture<Template> rollbackAsync(@NonNull String versionNumber) {
+    String versionNumberString = String.valueOf(versionNumber);
+    return rollbackOp(versionNumberString).callAsync(app);
+  }
+
+  private CallableOperation<Template, FirebaseRemoteConfigException> rollbackOp(
+          final String versionNumber) {
+    final FirebaseRemoteConfigClient remoteConfigClient = getRemoteConfigClient();
+    return new CallableOperation<Template, FirebaseRemoteConfigException>() {
+      @Override
+      protected Template execute() throws FirebaseRemoteConfigException {
+        return remoteConfigClient.rollback(versionNumber);
+      }
+    };
+  }
+
   @VisibleForTesting
   FirebaseRemoteConfigClient getRemoteConfigClient() {
     return remoteConfigClient;
diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java
index a218218..fba0bfd 100644
--- a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java
+++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java
@@ -33,4 +33,6 @@ interface FirebaseRemoteConfigClient {
 
   Template publishTemplate(Template template, boolean validateOnly,
                                   boolean forcePublish) throws FirebaseRemoteConfigException;
+
+  Template rollback(String versionNumber) throws FirebaseRemoteConfigException;
 }
diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java
index c289b43..824d91a 100644
--- a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java
+++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java
@@ -103,7 +103,8 @@ final class FirebaseRemoteConfigClientImpl implements FirebaseRemoteConfigClient
   }
 
   @Override
-  public Template getTemplateAtVersion(String versionNumber) throws FirebaseRemoteConfigException {
+  public Template getTemplateAtVersion(
+          @NonNull String versionNumber) throws FirebaseRemoteConfigException {
     checkArgument(isValidVersionNumber(versionNumber),
             "Version number must be a non-empty string in int64 format.");
     HttpRequestInfo request = HttpRequestInfo.buildGetRequest(remoteConfigUrl)
@@ -138,6 +139,20 @@ final class FirebaseRemoteConfigClientImpl implements FirebaseRemoteConfigClient
     return publishedTemplate.setETag(getETag(response));
   }
 
+  @Override
+  public Template rollback(@NonNull String versionNumber) throws FirebaseRemoteConfigException {
+    checkArgument(isValidVersionNumber(versionNumber),
+            "Version number must be a non-empty string in int64 format.");
+    Map<String, String> content = ImmutableMap.of("versionNumber", versionNumber);
+    HttpRequestInfo request = HttpRequestInfo
+            .buildJsonPostRequest(remoteConfigUrl + ":rollback", content)
+            .addAllHeaders(COMMON_HEADERS);
+    IncomingHttpResponse response = httpClient.send(request);
+    TemplateResponse templateResponse = httpClient.parse(response, TemplateResponse.class);
+    Template template = new Template(templateResponse);
+    return template.setETag(getETag(response));
+  }
+
   private String getETag(IncomingHttpResponse response) {
     List<String> etagList = (List<String>) response.getHeaders().get("etag");
     checkState(etagList != null && !etagList.isEmpty(),


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 6ca583b..5bf7c8d 100644
--- a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
+++ b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
@@ -32,6 +32,7 @@ import com.google.api.client.http.HttpRequest;
 import com.google.api.client.http.HttpResponseException;
 import com.google.api.client.http.HttpResponseInterceptor;
 import com.google.api.client.http.HttpTransport;
+import com.google.api.client.json.JsonParser;
 import com.google.api.client.testing.http.MockHttpTransport;
 import com.google.api.client.testing.http.MockLowLevelHttpResponse;
 import com.google.common.collect.ImmutableList;
@@ -46,7 +47,9 @@ import com.google.firebase.remoteconfig.internal.TemplateResponse;
 import com.google.firebase.testing.TestResponseInterceptor;
 import com.google.firebase.testing.TestUtils;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -709,6 +712,208 @@ public class FirebaseRemoteConfigClientImplTest {
     }
   }
 
+  // Test rollback
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testRollbackWithNullString() throws Exception {
+    client.rollback(null);
+  }
+
+  @Test
+  public void testRollbackWithInvalidString() throws Exception {
+    List<String> invalidVersionStrings = ImmutableList
+            .of("", " ", "abc", "t123", "123t", "t123t", "12t3", "#$*&^", "-123", "+123", "123.4");
+
+    for (String version : invalidVersionStrings) {
+      try {
+        client.rollback(version);
+        fail("No error thrown for invalid version number");
+      } catch (IllegalArgumentException expected) {
+        String message = "Version number must be a non-empty string in int64 format.";
+        assertEquals(message, expected.getMessage());
+      }
+    }
+  }
+
+  @Test
+  public void testRollbackWithValidString() throws Exception {
+    response.addHeader("etag", TEST_ETAG);
+    response.setContent(MOCK_TEMPLATE_RESPONSE);
+
+    Template rolledBackTemplate = client.rollback("24");
+
+    assertEquals(TEST_ETAG, rolledBackTemplate.getETag());
+    assertEquals(EXPECTED_TEMPLATE, rolledBackTemplate);
+    assertEquals(1605423446000L, rolledBackTemplate.getVersion().getUpdateTime());
+    checkPostRequestHeader(interceptor.getLastRequest(), ":rollback");
+    checkRequestContent(interceptor.getLastRequest(),
+            ImmutableMap.<String, Object>of("versionNumber", "24"));
+  }
+
+  @Test
+  public void testRollbackWithEmptyTemplateResponse() throws Exception {
+    response.addHeader("etag", TEST_ETAG);
+    response.setContent("{}");
+
+    Template template = client.rollback("24");
+
+    assertEquals(TEST_ETAG, template.getETag());
+    assertEquals(0, template.getParameters().size());
+    assertEquals(0, template.getConditions().size());
+    assertEquals(0, template.getParameterGroups().size());
+    assertNull(template.getVersion());
+    checkPostRequestHeader(interceptor.getLastRequest(), ":rollback");
+    checkRequestContent(interceptor.getLastRequest(),
+            ImmutableMap.<String, Object>of("versionNumber", "24"));
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testRollbackWithNoEtag() throws FirebaseRemoteConfigException {
+    // ETag does not exist
+    response.setContent(MOCK_TEMPLATE_RESPONSE);
+
+    client.rollback("24");
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testRollbackWithEmptyEtag() throws FirebaseRemoteConfigException {
+    // Empty ETag
+    response.addHeader("etag", "");
+    response.setContent(MOCK_TEMPLATE_RESPONSE);
+
+    client.rollback("24");
+  }
+
+  @Test
+  public void testRollbackHttpError() throws IOException {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent("{}");
+
+      try {
+        client.rollback("24");
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
+                "Unexpected HTTP response with status: " + code + "\n{}", HttpMethods.POST);
+      }
+      checkPostRequestHeader(interceptor.getLastRequest(), ":rollback");
+      checkRequestContent(interceptor.getLastRequest(),
+              ImmutableMap.<String, Object>of("versionNumber", "24"));
+    }
+  }
+
+  @Test
+  public void testRollbackTransportError() {
+    client = initClientWithFaultyTransport();
+
+    try {
+      client.rollback("24");
+      fail("No error thrown for HTTP error");
+    } catch (FirebaseRemoteConfigException error) {
+      assertEquals(ErrorCode.UNKNOWN, error.getErrorCode());
+      assertEquals("Unknown error while making a remote service call: transport error",
+              error.getMessage());
+      assertTrue(error.getCause() instanceof IOException);
+      assertNull(error.getHttpResponse());
+      assertNull(error.getRemoteConfigErrorCode());
+    }
+  }
+
+  @Test
+  public void testRollbackSuccessResponseWithUnexpectedPayload() throws IOException {
+    response.setContent("not valid json");
+
+    try {
+      client.rollback("24");
+      fail("No error thrown for malformed response");
+    } catch (FirebaseRemoteConfigException error) {
+      assertEquals(ErrorCode.UNKNOWN, error.getErrorCode());
+      assertTrue(error.getMessage().startsWith("Error while parsing HTTP response: "));
+      assertNotNull(error.getCause());
+      assertNotNull(error.getHttpResponse());
+      assertNull(error.getRemoteConfigErrorCode());
+    }
+    checkPostRequestHeader(interceptor.getLastRequest(), ":rollback");
+    checkRequestContent(interceptor.getLastRequest(),
+            ImmutableMap.<String, Object>of("versionNumber", "24"));
+  }
+
+  @Test
+  public void testRollbackErrorWithZeroContentResponse() throws IOException {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setZeroContent();
+
+      try {
+        client.rollback("24");
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
+                "Unexpected HTTP response with status: " + code + "\nnull", HttpMethods.POST);
+      }
+      checkPostRequestHeader(interceptor.getLastRequest(), ":rollback");
+      checkRequestContent(interceptor.getLastRequest(),
+              ImmutableMap.<String, Object>of("versionNumber", "24"));
+    }
+  }
+
+  @Test
+  public void testRollbackErrorWithMalformedResponse() throws IOException {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent("not json");
+
+      try {
+        client.rollback("24");
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null,
+                "Unexpected HTTP response with status: " + code + "\nnot json", HttpMethods.POST);
+      }
+      checkPostRequestHeader(interceptor.getLastRequest(), ":rollback");
+      checkRequestContent(interceptor.getLastRequest(),
+              ImmutableMap.<String, Object>of("versionNumber", "24"));
+    }
+  }
+
+  @Test
+  public void testRollbackErrorWithDetails() throws IOException {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent(
+              "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}");
+
+      try {
+        client.rollback("24");
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, null, "test error",
+                HttpMethods.POST);
+      }
+      checkPostRequestHeader(interceptor.getLastRequest(), ":rollback");
+      checkRequestContent(interceptor.getLastRequest(),
+              ImmutableMap.<String, Object>of("versionNumber", "24"));
+    }
+  }
+
+  @Test
+  public void testRollbackErrorWithRcError() throws IOException {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent(
+              "{\"error\": {\"status\": \"INVALID_ARGUMENT\", "
+                      + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}");
+
+      try {
+        client.rollback("24");
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT,
+                RemoteConfigErrorCode.INVALID_ARGUMENT, "[INVALID_ARGUMENT]: test error",
+                HttpMethods.POST);
+      }
+      checkPostRequestHeader(interceptor.getLastRequest(), ":rollback");
+      checkRequestContent(interceptor.getLastRequest(),
+              ImmutableMap.<String, Object>of("versionNumber", "24"));
+    }
+  }
+
   // App related tests
 
   @Test(expected = IllegalArgumentException.class)
@@ -782,9 +987,9 @@ public class FirebaseRemoteConfigClientImplTest {
     checkGetRequestHeader(request, "");
   }
 
-  private void checkGetRequestHeader(HttpRequest request, String query) {
+  private void checkGetRequestHeader(HttpRequest request, String urlSuffix) {
     assertEquals("GET", request.getRequestMethod());
-    assertEquals(TEST_REMOTE_CONFIG_URL + query, request.getUrl().toString());
+    assertEquals(TEST_REMOTE_CONFIG_URL + urlSuffix, request.getUrl().toString());
     HttpHeaders headers = request.getHeaders();
     assertEquals("fire-admin-java/" + SdkUtils.getVersion(), headers.get("X-Firebase-Client"));
     assertEquals("gzip", headers.getAcceptEncoding());
@@ -794,15 +999,33 @@ public class FirebaseRemoteConfigClientImplTest {
     checkPutRequestHeader(request, "", TEST_ETAG);
   }
 
-  private void checkPutRequestHeader(HttpRequest request, String query, String ifMatch) {
+  private void checkPutRequestHeader(HttpRequest request, String urlSuffix, String ifMatch) {
     assertEquals("PUT", request.getRequestMethod());
-    assertEquals(TEST_REMOTE_CONFIG_URL + query, request.getUrl().toString());
+    assertEquals(TEST_REMOTE_CONFIG_URL + urlSuffix, request.getUrl().toString());
     HttpHeaders headers = request.getHeaders();
     assertEquals("fire-admin-java/" + SdkUtils.getVersion(), headers.get("X-Firebase-Client"));
     assertEquals("gzip", headers.getAcceptEncoding());
     assertEquals(ifMatch, headers.getIfMatch());
   }
 
+  private void checkPostRequestHeader(HttpRequest request, String urlSuffix) {
+    assertEquals("POST", request.getRequestMethod());
+    assertEquals(TEST_REMOTE_CONFIG_URL + urlSuffix, request.getUrl().toString());
+    HttpHeaders headers = request.getHeaders();
+    assertEquals("fire-admin-java/" + SdkUtils.getVersion(), headers.get("X-Firebase-Client"));
+    assertEquals("gzip", headers.getAcceptEncoding());
+  }
+
+  private void checkRequestContent(
+          HttpRequest request, Map<String, Object> expected) throws IOException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    request.getContent().writeTo(out);
+    JsonParser parser = Utils.getDefaultJsonFactory().createJsonParser(out.toString());
+    Map<String, Object> parsed = new HashMap<>();
+    parser.parseAndClose(parsed);
+    assertEquals(expected, parsed);
+  }
+
   private void checkExceptionFromHttpResponse(
           FirebaseRemoteConfigException error,
           ErrorCode expectedCode,
diff --git a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java
index ed9eaf2..cd3bfd8 100644
--- a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java
+++ b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java
@@ -401,6 +401,100 @@ public class FirebaseRemoteConfigTest {
     }
   }
 
+  // Rollback template tests
+
+  @Test
+  public void testRollbackWithStringValue() throws FirebaseRemoteConfigException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(
+            new Template().setETag(TEST_ETAG));
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    Template template = remoteConfig.rollback("64");
+
+    assertEquals(TEST_ETAG, template.getETag());
+  }
+
+  @Test
+  public void testRollbackWithStringValueFailure() {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.rollback("55");
+    } catch (FirebaseRemoteConfigException e) {
+      assertSame(TEST_EXCEPTION, e);
+    }
+  }
+
+  @Test
+  public void testRollbackAsyncWithStringValue() throws Exception {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(
+            new Template().setETag(TEST_ETAG));
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    Template template = remoteConfig.rollbackAsync("55").get();
+
+    assertEquals(TEST_ETAG, template.getETag());
+  }
+
+  @Test
+  public void testRollbackAsyncWithStringValueFailure() throws InterruptedException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.rollbackAsync("55").get();
+    } catch (ExecutionException e) {
+      assertSame(TEST_EXCEPTION, e.getCause());
+    }
+  }
+
+  @Test
+  public void testRollbackWithLongValue() throws FirebaseRemoteConfigException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(
+            new Template().setETag(TEST_ETAG));
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    Template template = remoteConfig.rollback(64L);
+
+    assertEquals(TEST_ETAG, template.getETag());
+  }
+
+  @Test
+  public void testRollbackWithLongValueFailure() {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.rollback(55L);
+    } catch (FirebaseRemoteConfigException e) {
+      assertSame(TEST_EXCEPTION, e);
+    }
+  }
+
+  @Test
+  public void testRollbackAsyncWithLongValue() throws Exception {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(
+            new Template().setETag(TEST_ETAG));
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    Template template = remoteConfig.rollbackAsync(55L).get();
+
+    assertEquals(TEST_ETAG, template.getETag());
+  }
+
+  @Test
+  public void testRollbackAsyncWithLongValueFailure() throws InterruptedException {
+    MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION);
+    FirebaseRemoteConfig remoteConfig = getRemoteConfig(client);
+
+    try {
+      remoteConfig.rollbackAsync(55L).get();
+    } catch (ExecutionException e) {
+      assertSame(TEST_EXCEPTION, e.getCause());
+    }
+  }
+
   private FirebaseRemoteConfig getRemoteConfig(FirebaseRemoteConfigClient client) {
     FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS);
     return new FirebaseRemoteConfig(app, client);
diff --git a/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java b/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java
index 4dc136b..3d0fd59 100644
--- a/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java
+++ b/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java
@@ -59,4 +59,12 @@ public class MockRemoteConfigClient implements FirebaseRemoteConfigClient{
     }
     return resultTemplate;
   }
+
+  @Override
+  public Template rollback(String versionNumber) throws FirebaseRemoteConfigException {
+    if (exception != null) {
+      throw exception;
+    }
+    return resultTemplate;
+  }
 }