Repository: firebase/firebase-admin-java
Pull request #477 (2020-09-10 20:49:42)

Add initial classes for Remote Config API
- Add `FirebaseRemoteConfigClient` interface and implementation
- Add basic classes for error handing
- Add unit tests for `FirebaseRemoteConfigClientImpl`

Note: merging to `remote-config` branch.

Related issue: #446 


Patch:
diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java
new file mode 100644
index 0000000..9c84450
--- /dev/null
+++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.remoteconfig;
+
+/**
+ * An interface for managing Firebase Remote Config templates.
+ */
+interface FirebaseRemoteConfigClient {
+
+  /**
+   * Gets the current active version of the Remote Config template.
+   *
+   * @return A {@link RemoteConfigTemplate}.
+   * @throws FirebaseRemoteConfigException If an error occurs while getting the template.
+   */
+  RemoteConfigTemplate getTemplate() throws FirebaseRemoteConfigException;
+}
diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java
new file mode 100644
index 0000000..9a7ef31
--- /dev/null
+++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.remoteconfig;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpResponseInterceptor;
+import com.google.api.client.json.JsonFactory;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseException;
+import com.google.firebase.ImplFirebaseTrampolines;
+import com.google.firebase.IncomingHttpResponse;
+import com.google.firebase.internal.AbstractPlatformErrorHandler;
+import com.google.firebase.internal.ApiClientUtils;
+import com.google.firebase.internal.ErrorHandlingHttpClient;
+import com.google.firebase.internal.HttpRequestInfo;
+import com.google.firebase.internal.SdkUtils;
+import com.google.firebase.remoteconfig.internal.RemoteConfigServiceErrorResponse;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A helper class for interacting with Firebase Remote Config service.
+ */
+final class FirebaseRemoteConfigClientImpl implements FirebaseRemoteConfigClient {
+
+  private static final String REMOTE_CONFIG_URL = "https://firebaseremoteconfig.googleapis.com/v1/projects/%s/remoteConfig";
+
+  private static final Map<String, String> COMMON_HEADERS =
+          ImmutableMap.of(
+                  "X-Firebase-Client", "fire-admin-java/" + SdkUtils.getVersion(),
+                  // There is a known issue in which the ETag is not properly returned in cases
+                  // where the request does not specify a compression type. Currently, it is
+                  // required to include the header `Accept-Encoding: gzip` or equivalent in all
+                  // requests. https://firebase.google.com/docs/remote-config/use-config-rest#etag_usage_and_forced_updates
+                  "Accept-Encoding", "gzip"
+          );
+
+  private final String remoteConfigUrl;
+  private final HttpRequestFactory requestFactory;
+  private final JsonFactory jsonFactory;
+  private final ErrorHandlingHttpClient<FirebaseRemoteConfigException> httpClient;
+
+  private FirebaseRemoteConfigClientImpl(Builder builder) {
+    checkArgument(!Strings.isNullOrEmpty(builder.projectId));
+    this.remoteConfigUrl = String.format(REMOTE_CONFIG_URL, builder.projectId);
+    this.requestFactory = checkNotNull(builder.requestFactory);
+    this.jsonFactory = checkNotNull(builder.jsonFactory);
+    HttpResponseInterceptor responseInterceptor = builder.responseInterceptor;
+    RemoteConfigErrorHandler errorHandler = new RemoteConfigErrorHandler(this.jsonFactory);
+    this.httpClient = new ErrorHandlingHttpClient<>(requestFactory, jsonFactory, errorHandler)
+            .setInterceptor(responseInterceptor);
+  }
+
+  @VisibleForTesting
+  String getRemoteConfigUrl() {
+    return remoteConfigUrl;
+  }
+
+  @VisibleForTesting
+  HttpRequestFactory getRequestFactory() {
+    return requestFactory;
+  }
+
+  @VisibleForTesting
+  JsonFactory getJsonFactory() {
+    return jsonFactory;
+  }
+
+  @Override
+  public RemoteConfigTemplate getTemplate() throws FirebaseRemoteConfigException {
+    HttpRequestInfo request = HttpRequestInfo.buildGetRequest(remoteConfigUrl)
+            .addAllHeaders(COMMON_HEADERS);
+    IncomingHttpResponse response = httpClient.send(request);
+    RemoteConfigTemplate parsed = httpClient.parse(response, RemoteConfigTemplate.class);
+    parsed.setETag(getETag(response));
+    return parsed;
+  }
+
+  private String getETag(IncomingHttpResponse response) {
+    List<String> etagList = (List<String>) response.getHeaders().get("etag");
+    checkState(etagList != null && !etagList.isEmpty(),
+            "ETag header is not available in the server response.");
+
+    String etag = etagList.get(0);
+    checkState(!Strings.isNullOrEmpty(etag),
+            "ETag header is not available in the server response.");
+
+    return etag;
+  }
+
+  static FirebaseRemoteConfigClientImpl fromApp(FirebaseApp app) {
+    String projectId = ImplFirebaseTrampolines.getProjectId(app);
+    checkArgument(!Strings.isNullOrEmpty(projectId),
+            "Project ID is required to access Remote Config service. Use a service "
+                    + "account credential or set the project ID explicitly via FirebaseOptions. "
+                    + "Alternatively you can also set the project ID via the GOOGLE_CLOUD_PROJECT "
+                    + "environment variable.");
+    return FirebaseRemoteConfigClientImpl.builder()
+            .setProjectId(projectId)
+            .setRequestFactory(ApiClientUtils.newAuthorizedRequestFactory(app))
+            .setJsonFactory(app.getOptions().getJsonFactory())
+            .build();
+  }
+
+  static Builder builder() {
+    return new Builder();
+  }
+
+  static final class Builder {
+
+    private String projectId;
+    private HttpRequestFactory requestFactory;
+    private JsonFactory jsonFactory;
+    private HttpResponseInterceptor responseInterceptor;
+
+    private Builder() { }
+
+    Builder setProjectId(String projectId) {
+      this.projectId = projectId;
+      return this;
+    }
+
+    Builder setRequestFactory(HttpRequestFactory requestFactory) {
+      this.requestFactory = requestFactory;
+      return this;
+    }
+
+    Builder setJsonFactory(JsonFactory jsonFactory) {
+      this.jsonFactory = jsonFactory;
+      return this;
+    }
+
+    Builder setResponseInterceptor(
+            HttpResponseInterceptor responseInterceptor) {
+      this.responseInterceptor = responseInterceptor;
+      return this;
+    }
+
+    FirebaseRemoteConfigClientImpl build() {
+      return new FirebaseRemoteConfigClientImpl(this);
+    }
+  }
+
+  private static class RemoteConfigErrorHandler
+          extends AbstractPlatformErrorHandler<FirebaseRemoteConfigException> {
+
+    private RemoteConfigErrorHandler(JsonFactory jsonFactory) {
+      super(jsonFactory);
+    }
+
+    @Override
+    protected FirebaseRemoteConfigException createException(FirebaseException base) {
+      String response = getResponse(base);
+      RemoteConfigServiceErrorResponse parsed = safeParse(response);
+      return FirebaseRemoteConfigException.withRemoteConfigErrorCode(
+              base, parsed.getRemoteConfigErrorCode());
+    }
+
+    private String getResponse(FirebaseException base) {
+      if (base.getHttpResponse() == null) {
+        return null;
+      }
+
+      return base.getHttpResponse().getContent();
+    }
+
+    private RemoteConfigServiceErrorResponse safeParse(String response) {
+      if (!Strings.isNullOrEmpty(response)) {
+        try {
+          return jsonFactory.createJsonParser(response)
+                  .parseAndClose(RemoteConfigServiceErrorResponse.class);
+        } catch (IOException ignore) {
+          // Ignore any error that may occur while parsing the error response. The server
+          // may have responded with a non-json payload.
+        }
+      }
+
+      return new RemoteConfigServiceErrorResponse();
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigException.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigException.java
new file mode 100644
index 0000000..66b066f
--- /dev/null
+++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigException.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.remoteconfig;
+
+import com.google.firebase.ErrorCode;
+import com.google.firebase.FirebaseException;
+import com.google.firebase.IncomingHttpResponse;
+import com.google.firebase.internal.NonNull;
+import com.google.firebase.internal.Nullable;
+
+/**
+ * Generic exception related to Firebase Remote Config. Check the error code and message for more
+ * details.
+ */
+public final class FirebaseRemoteConfigException extends FirebaseException {
+
+  private final RemoteConfigErrorCode errorCode;
+
+  public FirebaseRemoteConfigException(
+          @NonNull ErrorCode errorCode,
+          @NonNull String message,
+          @Nullable Throwable cause,
+          @Nullable IncomingHttpResponse response,
+          @Nullable RemoteConfigErrorCode remoteConfigErrorCode) {
+    super(errorCode, message, cause, response);
+    this.errorCode = remoteConfigErrorCode;
+  }
+
+  static FirebaseRemoteConfigException withRemoteConfigErrorCode(
+          FirebaseException base, @Nullable RemoteConfigErrorCode errorCode) {
+    return new FirebaseRemoteConfigException(
+            base.getErrorCode(),
+            base.getMessage(),
+            base.getCause(),
+            base.getHttpResponse(),
+            errorCode);
+  }
+
+  @Nullable
+  public RemoteConfigErrorCode getRemoteConfigErrorCode() {
+    return errorCode;
+  }
+}
diff --git a/src/main/java/com/google/firebase/remoteconfig/RemoteConfigErrorCode.java b/src/main/java/com/google/firebase/remoteconfig/RemoteConfigErrorCode.java
new file mode 100644
index 0000000..6aa9402
--- /dev/null
+++ b/src/main/java/com/google/firebase/remoteconfig/RemoteConfigErrorCode.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.remoteconfig;
+
+/**
+ * Error codes that can be raised by the Remote Config APIs.
+ */
+public enum RemoteConfigErrorCode {
+
+  /**
+   * One or more arguments specified in the request were invalid.
+   */
+  INVALID_ARGUMENT,
+
+  /**
+   * Internal server error.
+   */
+  INTERNAL,
+}
diff --git a/src/main/java/com/google/firebase/remoteconfig/RemoteConfigTemplate.java b/src/main/java/com/google/firebase/remoteconfig/RemoteConfigTemplate.java
new file mode 100644
index 0000000..e8028b0
--- /dev/null
+++ b/src/main/java/com/google/firebase/remoteconfig/RemoteConfigTemplate.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.remoteconfig;
+
+import com.google.api.client.util.Key;
+
+public final class RemoteConfigTemplate {
+
+  @Key("etag")
+  private String etag;
+
+  public String getETag() {
+    return this.etag;
+  }
+
+  void setETag(String etag) {
+    this.etag = etag;
+  }
+}
diff --git a/src/main/java/com/google/firebase/remoteconfig/internal/RemoteConfigServiceErrorResponse.java b/src/main/java/com/google/firebase/remoteconfig/internal/RemoteConfigServiceErrorResponse.java
new file mode 100644
index 0000000..0c517e1
--- /dev/null
+++ b/src/main/java/com/google/firebase/remoteconfig/internal/RemoteConfigServiceErrorResponse.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.remoteconfig.internal;
+
+import com.google.api.client.json.GenericJson;
+import com.google.api.client.util.Key;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.internal.Nullable;
+import com.google.firebase.remoteconfig.RemoteConfigErrorCode;
+
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * The DTO for parsing error responses from the Remote Config service.
+ * These error messages take the form,
+ * `"error": {"code": 123, "message": "[CODE]: Message Details", "status": "ERROR_STATUS"}`
+ */
+public final class RemoteConfigServiceErrorResponse extends GenericJson {
+
+  private static final Map<String, RemoteConfigErrorCode> RC_ERROR_CODES =
+          ImmutableMap.<String, RemoteConfigErrorCode>builder()
+                  .put("INTERNAL", RemoteConfigErrorCode.INTERNAL)
+                  .put("INVALID_ARGUMENT", RemoteConfigErrorCode.INVALID_ARGUMENT)
+                  .build();
+
+  private static final Pattern RC_ERROR_CODE_PATTERN = Pattern.compile("^\\[(\\w+)\\]:.*$");
+
+  @Key("error")
+  private Map<String, Object> error;
+
+  @Nullable
+  public RemoteConfigErrorCode getRemoteConfigErrorCode() {
+    if (error == null) {
+      return null;
+    }
+
+    String message = (String) error.get("message");
+    if (Strings.isNullOrEmpty(message)) {
+      return null;
+    }
+
+    Matcher errorMatcher = RC_ERROR_CODE_PATTERN.matcher(message);
+    if (errorMatcher.find()) {
+      String errorCode = errorMatcher.group(1);
+      return RC_ERROR_CODES.get(errorCode);
+    }
+
+    return null;
+  }
+}


Test patch:
diff --git a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
new file mode 100644
index 0000000..8b6e94c
--- /dev/null
+++ b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java
@@ -0,0 +1,312 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.remoteconfig;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpHeaders;
+import com.google.api.client.http.HttpMethods;
+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.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.ErrorCode;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.OutgoingHttpRequest;
+import com.google.firebase.auth.MockGoogleCredentials;
+import com.google.firebase.internal.SdkUtils;
+import com.google.firebase.testing.TestResponseInterceptor;
+import com.google.firebase.testing.TestUtils;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.Before;
+import org.junit.Test;
+
+public class FirebaseRemoteConfigClientImplTest {
+
+  private static final String TEST_REMOTE_CONFIG_URL =
+          "https://firebaseremoteconfig.googleapis.com/v1/projects/test-project/remoteConfig";
+
+  private static final List<Integer> HTTP_STATUS_CODES = ImmutableList.of(401, 404, 500);
+
+  private static final Map<Integer, ErrorCode> HTTP_STATUS_TO_ERROR_CODE = ImmutableMap.of(
+          401, ErrorCode.UNAUTHENTICATED,
+          404, ErrorCode.NOT_FOUND,
+          500, ErrorCode.INTERNAL);
+
+  private static final String MOCK_TEMPLATE_RESPONSE = "{\"conditions\": [], \"parameters\": {}}";
+
+  private static final String TEST_ETAG = "etag-123456789012-1";
+
+  private MockLowLevelHttpResponse response;
+  private TestResponseInterceptor interceptor;
+  private FirebaseRemoteConfigClient client;
+
+  @Before
+  public void setUp() {
+    response = new MockLowLevelHttpResponse();
+    interceptor = new TestResponseInterceptor();
+    client = initRemoteConfigClient(response, interceptor);
+  }
+
+  @Test
+  public void testGetTemplate() throws Exception {
+    response.addHeader("etag", TEST_ETAG);
+    response.setContent(MOCK_TEMPLATE_RESPONSE);
+
+    RemoteConfigTemplate template = client.getTemplate();
+
+    assertEquals(TEST_ETAG, template.getETag());
+    checkGetRequestHeader(interceptor.getLastRequest());
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testGetTemplateWithInvalidEtags() throws FirebaseRemoteConfigException {
+    // ETag does not exist
+    response.setContent(MOCK_TEMPLATE_RESPONSE);
+
+    client.getTemplate();
+
+    // Empty ETag
+    response.addHeader("etag", "");
+    response.setContent(MOCK_TEMPLATE_RESPONSE);
+
+    client.getTemplate();
+  }
+
+  @Test
+  public void testGetTemplateHttpError() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent("{}");
+
+      try {
+        client.getTemplate();
+        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{}");
+      }
+      checkGetRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testGetTemplateTransportError() {
+    client = initClientWithFaultyTransport();
+
+    try {
+      client.getTemplate();
+      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 testGetTemplateSuccessResponseWithUnexpectedPayload() {
+    response.setContent("not valid json");
+
+    try {
+      client.getTemplate();
+      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());
+    }
+    checkGetRequestHeader(interceptor.getLastRequest());
+  }
+
+  @Test
+  public void testGetTemplateErrorWithZeroContentResponse() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setZeroContent();
+
+      try {
+        client.getTemplate();
+        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");
+      }
+      checkGetRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testGetTemplateErrorWithMalformedResponse() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent("not json");
+
+      try {
+        client.getTemplate();
+        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");
+      }
+      checkGetRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testGetTemplateErrorWithDetails() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent(
+              "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}");
+
+      try {
+        client.getTemplate();
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, null, "test error");
+      }
+      checkGetRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test
+  public void testGetTemplateErrorWithRcError() {
+    for (int code : HTTP_STATUS_CODES) {
+      response.setStatusCode(code).setContent(
+              "{\"error\": {\"status\": \"INVALID_ARGUMENT\", "
+                      + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}");
+
+      try {
+        client.getTemplate();
+        fail("No error thrown for HTTP error");
+      } catch (FirebaseRemoteConfigException error) {
+        checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT,
+                RemoteConfigErrorCode.INVALID_ARGUMENT, "[INVALID_ARGUMENT]: test error");
+      }
+      checkGetRequestHeader(interceptor.getLastRequest());
+    }
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testBuilderNullProjectId() {
+    fullyPopulatedBuilder().setProjectId(null).build();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testBuilderEmptyProjectId() {
+    fullyPopulatedBuilder().setProjectId("").build();
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testBuilderNullRequestFactory() {
+    fullyPopulatedBuilder().setRequestFactory(null).build();
+  }
+
+  @Test
+  public void testFromApp() throws IOException {
+    FirebaseOptions options = FirebaseOptions.builder()
+            .setCredentials(new MockGoogleCredentials("test-token"))
+            .setProjectId("test-project")
+            .build();
+    FirebaseApp app = FirebaseApp.initializeApp(options);
+
+    try {
+      FirebaseRemoteConfigClientImpl client = FirebaseRemoteConfigClientImpl.fromApp(app);
+
+      assertEquals(TEST_REMOTE_CONFIG_URL, client.getRemoteConfigUrl());
+      assertSame(options.getJsonFactory(), client.getJsonFactory());
+
+      HttpRequest request = client.getRequestFactory().buildGetRequest(
+              new GenericUrl("https://example.com"));
+      assertEquals("Bearer test-token", request.getHeaders().getAuthorization());
+    } finally {
+      app.delete();
+    }
+  }
+
+  private FirebaseRemoteConfigClientImpl initRemoteConfigClient(
+          MockLowLevelHttpResponse mockResponse, HttpResponseInterceptor interceptor) {
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+            .setLowLevelHttpResponse(mockResponse)
+            .build();
+
+    return FirebaseRemoteConfigClientImpl.builder()
+            .setProjectId("test-project")
+            .setJsonFactory(Utils.getDefaultJsonFactory())
+            .setRequestFactory(transport.createRequestFactory())
+            .setResponseInterceptor(interceptor)
+            .build();
+  }
+
+  private FirebaseRemoteConfigClientImpl initClientWithFaultyTransport() {
+    HttpTransport transport = TestUtils.createFaultyHttpTransport();
+    return FirebaseRemoteConfigClientImpl.builder()
+            .setProjectId("test-project")
+            .setJsonFactory(Utils.getDefaultJsonFactory())
+            .setRequestFactory(transport.createRequestFactory())
+            .build();
+  }
+
+  private FirebaseRemoteConfigClientImpl.Builder fullyPopulatedBuilder() {
+    return FirebaseRemoteConfigClientImpl.builder()
+            .setProjectId("test-project")
+            .setJsonFactory(Utils.getDefaultJsonFactory())
+            .setRequestFactory(Utils.getDefaultTransport().createRequestFactory());
+  }
+
+  private void checkGetRequestHeader(HttpRequest request) {
+    assertEquals("GET", request.getRequestMethod());
+    assertEquals(TEST_REMOTE_CONFIG_URL, request.getUrl().toString());
+    HttpHeaders headers = request.getHeaders();
+    assertEquals("fire-admin-java/" + SdkUtils.getVersion(), headers.get("X-Firebase-Client"));
+    assertEquals("gzip", headers.getAcceptEncoding());
+  }
+
+  private void checkExceptionFromHttpResponse(
+          FirebaseRemoteConfigException error,
+          ErrorCode expectedCode,
+          RemoteConfigErrorCode expectedRemoteConfigCode,
+          String expectedMessage) {
+    assertEquals(expectedCode, error.getErrorCode());
+    assertEquals(expectedMessage, error.getMessage());
+    assertTrue(error.getCause() instanceof HttpResponseException);
+    assertEquals(expectedRemoteConfigCode, error.getRemoteConfigErrorCode());
+
+    assertNotNull(error.getHttpResponse());
+    OutgoingHttpRequest request = error.getHttpResponse().getRequest();
+    assertEquals(HttpMethods.GET, request.getMethod());
+    assertTrue(request.getUrl().startsWith("https://firebaseremoteconfig.googleapis.com"));
+  }
+}