Skip to content

Commit 191c41b

Browse files
committed
Handle API disabled errors in firebase-appdistribution
1 parent 4cec8a4 commit 191c41b

File tree

9 files changed

+299
-8
lines changed

9 files changed

+299
-8
lines changed

firebase-appdistribution-api/src/main/java/com/google/firebase/appdistribution/FirebaseAppDistributionException.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,17 @@ public enum Status {
7979
* com.google.firebase:firebase-appdistribution}.
8080
*/
8181
NOT_IMPLEMENTED,
82+
83+
/**
84+
* The Firebase App Distribution Tester API is disabled for this project.
85+
*
86+
* <p>The developer of this app must enable the API in the Google Cloud Console before using
87+
* the App Distribution SDK. See the
88+
* <a href="https://firebase.google.com/docs/app-distribution/set-up-alerts?platform=android">documentation</a>
89+
* for more information. If you enabled this API recently, wait a few minutes for the action to
90+
* propagate to our systems and retry.
91+
*/
92+
API_DISABLED,
8293
}
8394

8495
@NonNull private final Status status;

firebase-appdistribution/src/main/java/com/google/firebase/appdistribution/impl/ErrorMessages.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package com.google.firebase.appdistribution.impl;
1616

1717
class ErrorMessages {
18+
1819
static final String NETWORK_ERROR = "Request failed with unknown network error.";
1920

2021
static final String JSON_PARSING_ERROR =
@@ -51,5 +52,8 @@ class ErrorMessages {
5152
static final String APK_INSTALLATION_FAILED =
5253
"The APK failed to install or installation was canceled by the tester.";
5354

55+
static final String API_DISABLED =
56+
"The App Distribution Tester API is disabled. It must be enabled in the Google Cloud Console. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.";
57+
5458
private ErrorMessages() {}
5559
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.google.firebase.appdistribution.impl;
2+
3+
import androidx.annotation.Nullable;
4+
import com.google.auto.value.AutoValue;
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
import org.json.JSONArray;
8+
import org.json.JSONException;
9+
import org.json.JSONObject;
10+
11+
/**
12+
* Details about a {@code SERVICE_DISABLED} error returned by the Tester API.
13+
*
14+
* <p>Error structure is described in the <a
15+
* href="https://cloud.google.com/apis/design/errors#error_details">Cloud APIs documentation</a>.
16+
*/
17+
@AutoValue
18+
abstract class TesterApiDisabledErrorDetails {
19+
20+
@AutoValue
21+
abstract static class HelpLink {
22+
abstract String description();
23+
24+
abstract String url();
25+
26+
static HelpLink create(String description, String url) {
27+
return new AutoValue_TesterApiDisabledErrorDetails_HelpLink(description, url);
28+
}
29+
}
30+
31+
abstract List<HelpLink> helpLinks();
32+
33+
String formatLinks() {
34+
StringBuilder stringBuilder = new StringBuilder();
35+
for (HelpLink link : helpLinks()) {
36+
stringBuilder.append(String.format("%s: %s\n", link.description(), link.url()));
37+
}
38+
return stringBuilder.toString();
39+
}
40+
41+
/**
42+
* Try to parse API disabled error details from a response body.
43+
*
44+
* <p>If the response is an API disabled error but there is a failure parsing the help links, it
45+
* will still return the details with any links it could parse before the failure.
46+
*
47+
* @param responseBody
48+
* @return the details, or {@code null} if the response was not in the expected format
49+
*/
50+
@Nullable
51+
static TesterApiDisabledErrorDetails tryParse(String responseBody) {
52+
try {
53+
// Get the error details object
54+
JSONArray details =
55+
new JSONObject(responseBody).getJSONObject("error").getJSONArray("details");
56+
JSONObject errorInfo = getDetailWithType(details, "type.googleapis.com/google.rpc.ErrorInfo");
57+
if (errorInfo.getString("reason").equals("SERVICE_DISABLED")) {
58+
return new AutoValue_TesterApiDisabledErrorDetails(parseHelpLinks(details));
59+
}
60+
} catch (JSONException e) {
61+
// Error was not in expected API disabled error format
62+
}
63+
return null;
64+
}
65+
66+
private static JSONObject getDetailWithType(JSONArray details, String type) throws JSONException {
67+
for (int i = 0; i < details.length(); i++) {
68+
JSONObject detail = details.getJSONObject(i);
69+
if (detail.getString("@type").equals(type)) {
70+
return detail;
71+
}
72+
}
73+
throw new JSONException("No detail present with type: " + type);
74+
}
75+
76+
static List<HelpLink> parseHelpLinks(JSONArray details) {
77+
List<HelpLink> helpLinks = new ArrayList<>();
78+
try {
79+
JSONObject help = getDetailWithType(details, "type.googleapis.com/google.rpc.Help");
80+
JSONArray linksJson = help.getJSONArray("links");
81+
for (int i = 0; i < linksJson.length(); i++) {
82+
helpLinks.add(parseHelpLink(linksJson.getJSONObject(i)));
83+
}
84+
} catch (JSONException e) {
85+
// If we have an issue parsing the links, we don't want to fail the entire error parsing, so
86+
// go ahead and return what we have
87+
}
88+
return helpLinks;
89+
}
90+
91+
private static HelpLink parseHelpLink(JSONObject json) throws JSONException {
92+
String description = json.getString("description");
93+
String url = json.getString("url");
94+
return HelpLink.create(description, url);
95+
}
96+
}

firebase-appdistribution/src/main/java/com/google/firebase/appdistribution/impl/TesterApiHttpClient.java

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ private static JSONObject readResponse(String tag, HttpsURLConnection connection
182182
String responseBody = readResponseBody(connection);
183183
LogWrapper.getInstance().v(tag, String.format("Response (%d): %s", responseCode, responseBody));
184184
if (!isResponseSuccess(responseCode)) {
185-
throw getExceptionForHttpResponse(tag, responseCode);
185+
throw getExceptionForHttpResponse(tag, responseCode, responseBody);
186186
}
187187
return parseJson(tag, responseBody);
188188
}
@@ -234,18 +234,15 @@ private HttpsURLConnection openHttpsUrlConnection(String url, String authToken)
234234
}
235235

236236
private static FirebaseAppDistributionException getExceptionForHttpResponse(
237-
String tag, int responseCode) {
237+
String tag, int responseCode, String responseBody) {
238238
switch (responseCode) {
239239
case 400:
240240
return getException(tag, "Bad request", Status.UNKNOWN);
241241
case 401:
242242
return getException(tag, ErrorMessages.AUTHENTICATION_ERROR, Status.AUTHENTICATION_FAILURE);
243243
case 403:
244-
return getException(tag, ErrorMessages.AUTHORIZATION_ERROR, Status.AUTHENTICATION_FAILURE);
244+
return getExceptionFor403(tag, responseBody);
245245
case 404:
246-
// TODO(lkellogg): Change this to a different status once 404s no longer indicate missing
247-
// access (the backend should return 403s for those cases, including when the resource
248-
// doesn't exist but the tester doesn't have the access to see that information)
249246
return getException(tag, ErrorMessages.NOT_FOUND_ERROR, Status.AUTHENTICATION_FAILURE);
250247
case 408:
251248
case 504:
@@ -255,6 +252,22 @@ private static FirebaseAppDistributionException getExceptionForHttpResponse(
255252
}
256253
}
257254

255+
private static FirebaseAppDistributionException getExceptionFor403(
256+
String tag, String responseBody) {
257+
// Check if this is an API disabled error
258+
TesterApiDisabledErrorDetails apiDisabledErrorDetails =
259+
TesterApiDisabledErrorDetails.tryParse(responseBody);
260+
if (apiDisabledErrorDetails != null) {
261+
String messageWithHelpLinks =
262+
String.format(
263+
"%s\n\n%s", ErrorMessages.API_DISABLED, apiDisabledErrorDetails.formatLinks());
264+
return getException(tag, messageWithHelpLinks, Status.API_DISABLED);
265+
}
266+
267+
// Otherwise return a basic 403 exception
268+
return getException(tag, ErrorMessages.AUTHORIZATION_ERROR, Status.AUTHENTICATION_FAILURE);
269+
}
270+
258271
private static FirebaseAppDistributionException getException(
259272
String tag, String message, Status status) {
260273
return new FirebaseAppDistributionException(tagMessage(tag, message), status);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"error": {
3+
"code": 403,
4+
"message": "Firebase App Testers API has not been used in project 123456789 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/firebaseapptesters.googleapis.com/overview?project=123456789 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.",
5+
"status": "PERMISSION_DENIED",
6+
"details": [
7+
{
8+
"@type": "type.googleapis.com/google.rpc.Help",
9+
"links": [
10+
{
11+
"description": "One link",
12+
"url": "http://google.com"
13+
},
14+
{
15+
"description": "Another link",
16+
"url": "http://gmail.com"
17+
},
18+
{
19+
"bad": "link"
20+
},
21+
{
22+
"description": "One more link",
23+
"url": "http://somethingelse.com"
24+
}
25+
]
26+
},
27+
{
28+
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
29+
"reason": "SERVICE_DISABLED",
30+
"domain": "googleapis.com",
31+
"metadata": {
32+
"consumer": "projects/123456789",
33+
"service": "firebaseapptesters.googleapis.com"
34+
}
35+
}
36+
]
37+
}
38+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"error": {
3+
"code": 403,
4+
"message": "Firebase App Testers API has not been used in project 123456789 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/firebaseapptesters.googleapis.com/overview?project=123456789 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.",
5+
"status": "PERMISSION_DENIED",
6+
"details": [
7+
{
8+
"@type": "type.googleapis.com/google.rpc.Help",
9+
"links": [
10+
{
11+
"description": "Google developers console API activation",
12+
"url": "https://console.developers.google.com/apis/api/firebaseapptesters.googleapis.com/overview?project=123456789"
13+
}
14+
]
15+
},
16+
{
17+
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
18+
"reason": "SERVICE_DISABLED",
19+
"domain": "googleapis.com",
20+
"metadata": {
21+
"consumer": "projects/123456789",
22+
"service": "firebaseapptesters.googleapis.com"
23+
}
24+
}
25+
]
26+
}
27+
}

firebase-appdistribution/src/test/java/com/google/firebase/appdistribution/impl/TestUtils.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,12 @@ private static <T> Answer<Task<T>> applyToForegroundActivityTaskAnswer(Activity
138138
};
139139
}
140140

141+
static InputStream getTestFileInputStream(String fileName) throws IOException {
142+
return getContext().getResources().getAssets().open(fileName);
143+
}
144+
141145
static String readTestFile(String fileName) throws IOException {
142-
final InputStream jsonInputStream = getContext().getResources().getAssets().open(fileName);
146+
final InputStream jsonInputStream = getTestFileInputStream(fileName);
143147
return streamToString(jsonInputStream);
144148
}
145149

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright 2022 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.appdistribution.impl;
16+
17+
import static com.google.common.truth.Truth.assertThat;
18+
19+
import com.google.firebase.appdistribution.impl.TesterApiDisabledErrorDetails.HelpLink;
20+
import java.io.IOException;
21+
import java.util.ArrayList;
22+
import java.util.List;
23+
import org.junit.Test;
24+
import org.junit.runner.RunWith;
25+
import org.robolectric.RobolectricTestRunner;
26+
27+
@RunWith(RobolectricTestRunner.class)
28+
public class TesterApiDisabledErrorDetailsTest {
29+
30+
@Test
31+
public void tryParse_success() throws IOException {
32+
String responseBody = TestUtils.readTestFile("apiDisabledResponse.json");
33+
34+
TesterApiDisabledErrorDetails details = TesterApiDisabledErrorDetails.tryParse(responseBody);
35+
36+
assertThat(details.helpLinks())
37+
.containsExactly(
38+
HelpLink.create(
39+
"Google developers console API activation",
40+
"https://console.developers.google.com/apis/api/firebaseapptesters.googleapis.com/overview?project=123456789"));
41+
}
42+
43+
@Test
44+
public void tryParse_badResponseBody_returnsNull() {
45+
String responseBody = "not json";
46+
47+
TesterApiDisabledErrorDetails details = TesterApiDisabledErrorDetails.tryParse(responseBody);
48+
49+
assertThat(details).isNull();
50+
}
51+
52+
@Test
53+
public void tryParse_errorParsingLinks_stillReturnsDetails() throws IOException {
54+
String responseBody = TestUtils.readTestFile("apiDisabledBadLinkResponse.json");
55+
56+
TesterApiDisabledErrorDetails details = TesterApiDisabledErrorDetails.tryParse(responseBody);
57+
58+
assertThat(details.helpLinks())
59+
.containsExactly(
60+
HelpLink.create("One link", "http://google.com"),
61+
HelpLink.create("Another link", "http://gmail.com"));
62+
}
63+
64+
@Test
65+
public void formatLinks_success() {
66+
List<HelpLink> helpLinks = new ArrayList<>();
67+
helpLinks.add(HelpLink.create("One link", "http://google.com"));
68+
helpLinks.add(HelpLink.create("Another link", "http://gmail.com"));
69+
TesterApiDisabledErrorDetails details = new AutoValue_TesterApiDisabledErrorDetails(helpLinks);
70+
71+
String formattedLinks = details.formatLinks();
72+
73+
assertThat(formattedLinks)
74+
.isEqualTo("One link: http://google.com\nAnother link: http://gmail.com\n");
75+
}
76+
}

firebase-appdistribution/src/test/java/com/google/firebase/appdistribution/impl/TesterApiHttpClientTest.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package com.google.firebase.appdistribution.impl;
1616

1717
import static com.google.common.truth.Truth.assertThat;
18+
import static com.google.firebase.appdistribution.impl.TestUtils.getTestFileInputStream;
1819
import static com.google.firebase.appdistribution.impl.TestUtils.readTestFile;
1920
import static java.nio.charset.StandardCharsets.UTF_8;
2021
import static org.junit.Assert.assertThrows;
@@ -47,7 +48,7 @@ public class TesterApiHttpClientTest {
4748

4849
private static final String TEST_API_KEY = "AIzaSyabcdefghijklmnopqrstuvwxyz1234567";
4950
private static final String TEST_APP_ID_1 = "1:123456789:android:abcdef";
50-
private static final String TEST_PROJECT_ID = "777777777777";
51+
private static final String TEST_PROJECT_ID = "project-id";
5152
private static final String TEST_AUTH_TOKEN = "fad.auth.token";
5253
private static final String INVALID_RESPONSE = "InvalidResponse";
5354
private static final String TEST_PATH = "some/url/path";
@@ -157,6 +158,27 @@ public void makeGetRequest_whenResponseFailsWith403_throwsError() throws Excepti
157158
verify(mockHttpsURLConnection).disconnect();
158159
}
159160

161+
@Test
162+
public void makeGetRequest_whenResponseFailsWithApiDisabled_throwsError() throws Exception {
163+
InputStream response = getTestFileInputStream("apiDisabledResponse.json");
164+
when(mockHttpsURLConnection.getErrorStream()).thenReturn(response);
165+
when(mockHttpsURLConnection.getResponseCode()).thenReturn(403);
166+
167+
FirebaseAppDistributionException e =
168+
assertThrows(
169+
FirebaseAppDistributionException.class,
170+
() -> testerApiHttpClient.makeGetRequest(TAG, TEST_PATH, TEST_AUTH_TOKEN));
171+
172+
assertThat(e.getErrorCode()).isEqualTo(Status.API_DISABLED);
173+
assertThat(e.getMessage()).contains(TAG);
174+
assertThat(e.getMessage()).contains(ErrorMessages.API_DISABLED);
175+
assertThat(e.getMessage()).contains("Google developers console API activation");
176+
assertThat(e.getMessage())
177+
.contains(
178+
"https://console.developers.google.com/apis/api/firebaseapptesters.googleapis.com/overview?project=123456789");
179+
verify(mockHttpsURLConnection).disconnect();
180+
}
181+
160182
@Test
161183
public void makeGetRequest_whenResponseFailsWith404_throwsError() throws Exception {
162184
when(mockHttpsURLConnection.getResponseCode()).thenReturn(404);

0 commit comments

Comments
 (0)