Repository: firebase/firebase-admin-java
Pull request #350 (2020-01-22 00:26:41)

Added core types for the error handling revamp
Starting to implement the error handling revamp. Since this contains sweeping breaking changes, I'm moving the implementation to a separate v7 branch.

* Added `ErrorCode`, `IncomingHttpResponse` and `OutgoingHttpRequest` types.
* Exposed error code and http response from the `FirebaseException` base class.
* Deprecated the old constructors in `FirebaseException`. These will be removed once the error handling revamp is fully implemented.

go/firebase-error-handling-java



Patch:
diff --git a/src/main/java/com/google/firebase/ErrorCode.java b/src/main/java/com/google/firebase/ErrorCode.java
new file mode 100644
index 0000000..217903c
--- /dev/null
+++ b/src/main/java/com/google/firebase/ErrorCode.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2020 Google Inc.
+ *
+ * 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;
+
+/**
+ * Platform-wide error codes that can be raised by Admin SDK APIs.
+ */
+public enum ErrorCode {
+
+  /**
+   * Client specified an invalid argument.
+   */
+  INVALID_ARGUMENT,
+
+  /**
+   * Request can not be executed in the current system state, such as deleting a non-empty
+   * directory.
+   */
+  FAILED_PRECONDITION,
+
+  /**
+   * Client specified an invalid range.
+   */
+  OUT_OF_RANGE,
+
+  /**
+   * Request not authenticated due to missing, invalid, or expired OAuth token.
+   */
+  UNAUTHENTICATED,
+
+  /**
+   * Client does not have sufficient permission. This can happen because the OAuth token does
+   * not have the right scopes, the client doesn't have permission, or the API has not been
+   * enabled for the client project.
+   */
+  PERMISSION_DENIED,
+
+  /**
+   * A specified resource is not found, or the request is rejected by undisclosed reasons,
+   * such as whitelisting.
+   */
+  NOT_FOUND,
+
+  /**
+   * Concurrency conflict, such as read-modify-write conflict.
+   */
+  CONFLICT,
+
+  /**
+   * Concurrency conflict, such as read-modify-write conflict.
+   */
+  ABORTED,
+
+  /**
+   * The resource that a client tried to create already exists.
+   */
+  ALREADY_EXISTS,
+
+  /**
+   * Either out of resource quota or reaching rate limiting.
+   */
+  RESOURCE_EXHAUSTED,
+
+  /**
+   * Request cancelled by the client.
+   */
+  CANCELLED,
+
+  /**
+   * Unrecoverable data loss or data corruption. The client should report the error to the user.
+   */
+  DATA_LOSS,
+
+  /**
+   * Unknown server error. Typically a server bug.
+   */
+  UNKNOWN,
+
+  /**
+   * Internal server error. Typically a server bug.
+   */
+  INTERNAL,
+
+  /**
+   * Service unavailable. Typically the server is down.
+   */
+  UNAVAILABLE,
+
+  /**
+   * Request deadline exceeded. This will happen only if the caller sets a deadline that is
+   * shorter than the method's default deadline (i.e. requested deadline is not enough for the
+   * server to process the request) and the request did not finish within the deadline.
+   */
+  DEADLINE_EXCEEDED,
+}
diff --git a/src/main/java/com/google/firebase/FirebaseException.java b/src/main/java/com/google/firebase/FirebaseException.java
index f78b3fb..f15c042 100644
--- a/src/main/java/com/google/firebase/FirebaseException.java
+++ b/src/main/java/com/google/firebase/FirebaseException.java
@@ -17,24 +17,59 @@
 package com.google.firebase;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.base.Strings;
 import com.google.firebase.internal.NonNull;
+import com.google.firebase.internal.Nullable;
 
-/** Base class for all Firebase exceptions. */
+/**
+ * Base class for all Firebase exceptions.
+ */
 public class FirebaseException extends Exception {
 
-  // TODO(b/27677218): Exceptions should have non-empty messages.
-  @Deprecated
-  protected FirebaseException() {}
+  private final ErrorCode errorCode;
+  private final IncomingHttpResponse httpResponse;
 
+  @Deprecated
   public FirebaseException(@NonNull String detailMessage) {
-    super(detailMessage);
-    checkArgument(!Strings.isNullOrEmpty(detailMessage), "Detail message must not be empty");
+    this(detailMessage, null);
   }
 
+  @Deprecated
   public FirebaseException(@NonNull String detailMessage, Throwable cause) {
-    super(detailMessage, cause);
-    checkArgument(!Strings.isNullOrEmpty(detailMessage), "Detail message must not be empty");
+    this(ErrorCode.UNKNOWN, detailMessage, null, cause);
+  }
+
+  public FirebaseException(
+      @NonNull ErrorCode errorCode,
+      @NonNull String message,
+      @Nullable IncomingHttpResponse httpResponse,
+      @Nullable Throwable cause) {
+    super(message, cause);
+    checkArgument(!Strings.isNullOrEmpty(message), "Message must not be null or empty");
+    this.errorCode = checkNotNull(errorCode, "ErrorCode must not be null");
+    this.httpResponse = httpResponse;
+  }
+
+  /**
+   * Returns the platform-wide error code associated with this exception.
+   *
+   * @return A Firebase error code.
+   */
+  // TODO: Expose this method publicly
+  ErrorCode getErrorCode() {
+    return errorCode;
+  }
+
+  /**
+   * Returns the HTTP response that resulted in this exception. If the exception was not caused by
+   * an HTTP error response, returns null.
+   *
+   * @return An HTTP response or null.
+   */
+  @Nullable
+  public IncomingHttpResponse getHttpResponse() {
+    return httpResponse;
   }
 }
diff --git a/src/main/java/com/google/firebase/IncomingHttpResponse.java b/src/main/java/com/google/firebase/IncomingHttpResponse.java
new file mode 100644
index 0000000..02bdcb7
--- /dev/null
+++ b/src/main/java/com/google/firebase/IncomingHttpResponse.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2020 Google Inc.
+ *
+ * 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;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpResponseException;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.database.annotations.Nullable;
+import java.util.Map;
+
+/**
+ * Contains the information that describe an HTTP response received by the SDK.
+ */
+public final class IncomingHttpResponse {
+
+  private final int statusCode;
+  private final String content;
+  private final Map<String, Object> headers;
+  private final OutgoingHttpRequest request;
+
+  IncomingHttpResponse(HttpResponse response, @Nullable String content) {
+    checkNotNull(response, "response must not be null");
+    this.statusCode = response.getStatusCode();
+    this.content = content;
+    this.headers = ImmutableMap.copyOf(response.getHeaders());
+    this.request = new OutgoingHttpRequest(response.getRequest());
+  }
+
+  IncomingHttpResponse(HttpResponseException e, HttpRequest request) {
+    this(e, new OutgoingHttpRequest(request));
+  }
+
+  IncomingHttpResponse(HttpResponseException e, OutgoingHttpRequest request) {
+    checkNotNull(e, "exception must not be null");
+    this.statusCode = e.getStatusCode();
+    this.content = e.getContent();
+    this.headers = ImmutableMap.copyOf(e.getHeaders());
+    this.request = checkNotNull(request, "request must not be null");
+  }
+
+  /**
+   * Returns the status code of the response.
+   *
+   * @return An HTTP status code (e.g. 500).
+   */
+  public int getStatusCode() {
+    return this.statusCode;
+  }
+
+  /**
+   * Returns the content of the response as a string.
+   *
+   * @return HTTP content or null.
+   */
+  @Nullable
+  public String getContent() {
+    return this.content;
+  }
+
+  /**
+   * Returns the headers set on the response.
+   *
+   * @return An immutable map of headers (possibly empty).
+   */
+  public Map<String, Object> getHeaders() {
+    return this.headers;
+  }
+
+  /**
+   * Returns the request that resulted in this response.
+   *
+   * @return An HTTP request.
+   */
+  public OutgoingHttpRequest getRequest() {
+    return request;
+  }
+}
diff --git a/src/main/java/com/google/firebase/OutgoingHttpRequest.java b/src/main/java/com/google/firebase/OutgoingHttpRequest.java
new file mode 100644
index 0000000..09a64b0
--- /dev/null
+++ b/src/main/java/com/google/firebase/OutgoingHttpRequest.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2020 Google Inc.
+ *
+ * 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;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.http.HttpContent;
+import com.google.api.client.http.HttpRequest;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.internal.Nullable;
+import java.util.Map;
+
+/**
+ * Contains the information that describe an HTTP request made by the SDK.
+ */
+public final class OutgoingHttpRequest {
+
+  private final String method;
+  private final String url;
+  private final HttpContent content;
+  private final Map<String, Object> headers;
+
+  OutgoingHttpRequest(String method, String url) {
+    checkArgument(!Strings.isNullOrEmpty(method), "method must not be null or empty");
+    checkArgument(!Strings.isNullOrEmpty(url), "url must not be empty");
+    this.method = method;
+    this.url = url;
+    this.content = null;
+    this.headers = ImmutableMap.of();
+  }
+
+  OutgoingHttpRequest(HttpRequest request) {
+    checkNotNull(request, "request must not be null");
+    this.method = request.getRequestMethod();
+    this.url = request.getUrl().toString();
+    this.content = request.getContent();
+    this.headers = ImmutableMap.copyOf(request.getHeaders());
+  }
+
+  /**
+   * Returns the HTTP method of the request.
+   *
+   * @return An HTTP method string (e.g. GET).
+   */
+  public String getMethod() {
+    return method;
+  }
+
+  /**
+   * Returns the URL of the request.
+   *
+   * @return An absolute HTTP URL.
+   */
+  public String getUrl() {
+    return url;
+  }
+
+  /**
+   * Returns any content that was sent with the request.
+   *
+   * @return HTTP content or null.
+   */
+  @Nullable
+  public HttpContent getContent() {
+    return content;
+  }
+
+  /**
+   * Returns the headers set on the request.
+   *
+   * @return An immutable map of headers (possibly empty).
+   */
+  public Map<String, Object> getHeaders() {
+    return headers;
+  }
+}


Test patch:
diff --git a/src/test/java/com/google/firebase/FirebaseAppTest.java b/src/test/java/com/google/firebase/FirebaseAppTest.java
index eee9efd..77191fa 100644
--- a/src/test/java/com/google/firebase/FirebaseAppTest.java
+++ b/src/test/java/com/google/firebase/FirebaseAppTest.java
@@ -579,16 +579,6 @@ public class FirebaseAppTest {
     assertEquals("hipster-chat-mock", firebaseApp.getOptions().getProjectId());
   }
 
-  @Test(expected = IllegalArgumentException.class)
-  public void testFirebaseExceptionNullDetail() {
-    new FirebaseException(null);
-  }
-
-  @Test(expected = IllegalArgumentException.class)
-  public void testFirebaseExceptionEmptyDetail() {
-    new FirebaseException("");
-  }
-
   @Test
   public void testFirebaseAppCreationWithEmptySupplier() {
     FirebaseApp.initializeApp(FirebaseOptions.builder()
diff --git a/src/test/java/com/google/firebase/FirebaseExceptionTest.java b/src/test/java/com/google/firebase/FirebaseExceptionTest.java
new file mode 100644
index 0000000..46d3cf0
--- /dev/null
+++ b/src/test/java/com/google/firebase/FirebaseExceptionTest.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2020 Google Inc.
+ *
+ * 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;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.http.HttpStatusCodes;
+import com.google.api.client.testing.http.MockLowLevelHttpRequest;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.firebase.testing.TestUtils;
+import java.io.IOException;
+import org.junit.Test;
+
+@SuppressWarnings("ThrowableNotThrown")
+public class FirebaseExceptionTest {
+
+  @Test(expected = NullPointerException.class)
+  public void testFirebaseExceptionWithoutErrorCode() {
+    new FirebaseException(
+        null,
+        "Test error",
+        null,
+        null);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testFirebaseExceptionWithNullMessage() {
+    new FirebaseException(
+        ErrorCode.INTERNAL,
+        null,
+        null,
+        null);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testFirebaseExceptionWithEmptyMessage() {
+    new FirebaseException(
+        ErrorCode.INTERNAL,
+        "",
+        null,
+        null);
+  }
+
+  @Test
+  public void testFirebaseExceptionWithoutResponseAndCause() {
+    FirebaseException exception = new FirebaseException(
+        ErrorCode.INTERNAL,
+        "Test error",
+        null,
+        null);
+
+    assertEquals(ErrorCode.INTERNAL, exception.getErrorCode());
+    assertEquals("Test error", exception.getMessage());
+    assertNull(exception.getHttpResponse());
+    assertNull(exception.getCause());
+  }
+
+  @Test
+  public void testFirebaseExceptionWithResponse() throws IOException {
+    HttpResponseException httpError = createHttpResponseException();
+    OutgoingHttpRequest request = new OutgoingHttpRequest(
+        "GET", "https://firebase.google.com");
+    IncomingHttpResponse response = new IncomingHttpResponse(httpError, request);
+
+    FirebaseException exception = new FirebaseException(
+        ErrorCode.INTERNAL,
+        "Test error",
+        response,
+        null);
+
+    assertEquals(ErrorCode.INTERNAL, exception.getErrorCode());
+    assertEquals("Test error", exception.getMessage());
+    assertSame(response, exception.getHttpResponse());
+    assertNull(exception.getCause());
+  }
+
+  @Test
+  public void testFirebaseExceptionWithCause() {
+    Exception cause = new Exception("root cause");
+
+    FirebaseException exception = new FirebaseException(
+        ErrorCode.INTERNAL,
+        "Test error",
+        null,
+        cause);
+
+    assertEquals(ErrorCode.INTERNAL, exception.getErrorCode());
+    assertEquals("Test error", exception.getMessage());
+    assertNull(exception.getHttpResponse());
+    assertSame(cause, exception.getCause());
+  }
+
+  @Test
+  public void testFirebaseExceptionLegacyConstructor() {
+    FirebaseException exception = new FirebaseException("Test error");
+
+    assertEquals(ErrorCode.UNKNOWN, exception.getErrorCode());
+    assertEquals("Test error", exception.getMessage());
+    assertNull(exception.getHttpResponse());
+    assertNull(exception.getCause());
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testFirebaseExceptionNullDetail() {
+    new FirebaseException(null);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testFirebaseExceptionEmptyDetail() {
+    new FirebaseException("");
+  }
+
+  private HttpResponseException createHttpResponseException() throws IOException {
+    MockLowLevelHttpResponse lowLevelResponse = new MockLowLevelHttpResponse()
+        .setStatusCode(HttpStatusCodes.STATUS_CODE_SERVER_ERROR)
+        .setContent("{}");
+    MockLowLevelHttpRequest lowLevelRequest = new MockLowLevelHttpRequest()
+        .setResponse(lowLevelResponse);
+    HttpRequest request = TestUtils.createRequest(lowLevelRequest);
+    try {
+      request.execute();
+      throw new IOException("HttpResponseException not thrown");
+    } catch (HttpResponseException e) {
+      return e;
+    }
+  }
+}
diff --git a/src/test/java/com/google/firebase/IncomingHttpResponseTest.java b/src/test/java/com/google/firebase/IncomingHttpResponseTest.java
new file mode 100644
index 0000000..4da63d1
--- /dev/null
+++ b/src/test/java/com/google/firebase/IncomingHttpResponseTest.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2020 Google Inc.
+ *
+ * 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;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpMethods;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.http.HttpStatusCodes;
+import com.google.api.client.testing.http.MockLowLevelHttpRequest;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.testing.TestUtils;
+import java.io.IOException;
+import java.util.Map;
+import org.junit.Test;
+
+public class IncomingHttpResponseTest {
+
+  private static final String TEST_URL = "https://firebase.google.com/response";
+  private static final OutgoingHttpRequest REQUEST = new OutgoingHttpRequest("GET", TEST_URL);
+  private static final Map<String, Object> RESPONSE_HEADERS =
+      ImmutableMap.<String, Object>of("x-firebase-client", ImmutableList.of("test-version"));
+  private static final String RESPONSE_BODY = "test response";
+
+  @Test(expected = NullPointerException.class)
+  public void testNullHttpResponse() {
+    new IncomingHttpResponse(null, "content");
+  }
+
+  @Test
+  public void testNullHttpResponseException() throws IOException {
+    try {
+      new IncomingHttpResponse(null, REQUEST);
+      fail("No exception thrown for null HttpResponseException");
+    } catch (NullPointerException ignore) {
+      // expected
+    }
+
+    HttpRequest request = createHttpRequest();
+    try {
+      new IncomingHttpResponse(null, request);
+      fail("No exception thrown for null HttpResponseException");
+    } catch (NullPointerException ignore) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testIncomingHttpResponse() throws IOException {
+    HttpResponseException httpError = createHttpResponseException();
+
+    IncomingHttpResponse response = new IncomingHttpResponse(httpError, REQUEST);
+
+    assertEquals(HttpStatusCodes.STATUS_CODE_SERVER_ERROR, response.getStatusCode());
+    assertEquals(RESPONSE_BODY, response.getContent());
+    assertEquals(RESPONSE_HEADERS, response.getHeaders());
+    assertFalse(response.getHeaders().isEmpty());
+    assertSame(REQUEST, response.getRequest());
+  }
+
+  @Test
+  public void testIncomingHttpResponseWithRequest() throws IOException {
+    HttpResponseException httpError = createHttpResponseException();
+    HttpRequest httpRequest = createHttpRequest();
+
+    IncomingHttpResponse response = new IncomingHttpResponse(httpError, httpRequest);
+
+    assertEquals(HttpStatusCodes.STATUS_CODE_SERVER_ERROR, response.getStatusCode());
+    assertEquals(RESPONSE_BODY, response.getContent());
+    assertEquals(RESPONSE_HEADERS, response.getHeaders());
+    OutgoingHttpRequest request = response.getRequest();
+    assertEquals(HttpMethods.POST, request.getMethod());
+    assertEquals(TEST_URL, request.getUrl());
+  }
+
+  @Test
+  public void testIncomingHttpResponseWithResponse() throws IOException {
+    HttpResponse httpResponse = createHttpResponse();
+
+    IncomingHttpResponse response = new IncomingHttpResponse(httpResponse, RESPONSE_BODY);
+
+    assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatusCode());
+    assertEquals(RESPONSE_BODY, response.getContent());
+    assertTrue(response.getHeaders().isEmpty());
+    OutgoingHttpRequest request = response.getRequest();
+    assertEquals(HttpMethods.POST, request.getMethod());
+    assertEquals(TEST_URL, request.getUrl());
+  }
+
+  private HttpResponseException createHttpResponseException() throws IOException {
+    MockLowLevelHttpResponse lowLevelResponse = new MockLowLevelHttpResponse()
+        .setStatusCode(HttpStatusCodes.STATUS_CODE_SERVER_ERROR)
+        .addHeader("X-Firebase-Client", "test-version")
+        .setContent(RESPONSE_BODY);
+    MockLowLevelHttpRequest lowLevelRequest = new MockLowLevelHttpRequest()
+        .setResponse(lowLevelResponse);
+    HttpRequest request = TestUtils.createRequest(lowLevelRequest, new GenericUrl(TEST_URL));
+    try {
+      request.execute();
+      throw new IOException("HttpResponseException not thrown");
+    } catch (HttpResponseException e) {
+      return e;
+    }
+  }
+
+  private HttpRequest createHttpRequest() throws IOException {
+    MockLowLevelHttpResponse lowLevelResponse = new MockLowLevelHttpResponse()
+        .setContent("{}");
+    MockLowLevelHttpRequest lowLevelRequest = new MockLowLevelHttpRequest()
+        .setResponse(lowLevelResponse);
+    return TestUtils.createRequest(lowLevelRequest, new GenericUrl(TEST_URL));
+  }
+
+  private HttpResponse createHttpResponse() throws IOException {
+    HttpRequest request = createHttpRequest();
+    return request.execute();
+  }
+}
diff --git a/src/test/java/com/google/firebase/OutgoingHttpRequestTest.java b/src/test/java/com/google/firebase/OutgoingHttpRequestTest.java
new file mode 100644
index 0000000..792d621
--- /dev/null
+++ b/src/test/java/com/google/firebase/OutgoingHttpRequestTest.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2020 Google Inc.
+ *
+ * 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;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpMethods;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.json.JsonHttpContent;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import org.junit.Test;
+
+public class OutgoingHttpRequestTest {
+
+  private static final String TEST_URL = "https://firebase.google.com/request";
+
+  @Test(expected = NullPointerException.class)
+  public void testNullHttpRequest() {
+    new OutgoingHttpRequest(null);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testNullMethod() {
+    new OutgoingHttpRequest(null, TEST_URL);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testEmptyMethod() {
+    new OutgoingHttpRequest("", TEST_URL);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testNullUrl() {
+    new OutgoingHttpRequest(HttpMethods.GET, null);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testEmptyUrl() {
+    new OutgoingHttpRequest(HttpMethods.GET, "");
+  }
+
+  @Test
+  public void testOutgoingHttpRequest() {
+    OutgoingHttpRequest request = new OutgoingHttpRequest(HttpMethods.GET, TEST_URL);
+
+    assertEquals(HttpMethods.GET, request.getMethod());
+    assertEquals(TEST_URL, request.getUrl());
+    assertNull(request.getContent());
+    assertTrue(request.getHeaders().isEmpty());
+  }
+
+  @Test
+  public void testOutgoingHttpRequestWithContent() throws IOException {
+    JsonHttpContent streamingContent = new JsonHttpContent(
+        Utils.getDefaultJsonFactory(),
+        ImmutableMap.of("key", "value"));
+    HttpRequest httpRequest = new MockHttpTransport().createRequestFactory()
+        .buildPostRequest(new GenericUrl(TEST_URL), streamingContent);
+    httpRequest.getHeaders().set("X-Firebase-Client", "test-version");
+
+    OutgoingHttpRequest request = new OutgoingHttpRequest(httpRequest);
+
+    assertEquals(HttpMethods.POST, request.getMethod());
+    assertEquals(TEST_URL, request.getUrl());
+    assertSame(streamingContent, request.getContent());
+    assertEquals("test-version", request.getHeaders().get("x-firebase-client"));
+  }
+}
diff --git a/src/test/java/com/google/firebase/testing/TestUtils.java b/src/test/java/com/google/firebase/testing/TestUtils.java
index a63aa8d..e44ca2b 100644
--- a/src/test/java/com/google/firebase/testing/TestUtils.java
+++ b/src/test/java/com/google/firebase/testing/TestUtils.java
@@ -162,11 +162,19 @@ public class TestUtils {
   }
 
   public static HttpRequest createRequest(MockLowLevelHttpRequest request) throws IOException {
+    return createRequest(request, TEST_URL);
+  }
+
+  /**
+   * Creates a test HTTP POST request for the given target URL.
+   */
+  public static HttpRequest createRequest(
+      MockLowLevelHttpRequest request, GenericUrl url) throws IOException {
     HttpTransport transport = new MockHttpTransport.Builder()
         .setLowLevelHttpRequest(request)
         .build();
     HttpRequestFactory requestFactory = transport.createRequestFactory();
-    return requestFactory.buildPostRequest(TEST_URL, new EmptyContent());
+    return requestFactory.buildPostRequest(url, new EmptyContent());
   }
 
   public static HttpTransport createFaultyHttpTransport() {