Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,82 @@ protected Template execute() throws FirebaseRemoteConfigException {
};
}

/**
* 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,6 @@ interface FirebaseRemoteConfigClient {

Template publishTemplate(Template template, boolean validateOnly,
boolean forcePublish) throws FirebaseRemoteConfigException;

Template rollback(String versionNumber) throws FirebaseRemoteConfigException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ public Template getTemplate() throws FirebaseRemoteConfigException {
}

@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)
Expand Down Expand Up @@ -138,6 +139,20 @@ public Template publishTemplate(@NonNull Template template, boolean validateOnly
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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
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;
Expand All @@ -46,7 +47,9 @@
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;

Expand Down Expand Up @@ -709,6 +712,208 @@ public void testPublishTemplateErrorWithRcError() {
}
}

// 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)
Expand Down Expand Up @@ -782,9 +987,9 @@ private void checkGetRequestHeader(HttpRequest request) {
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());
Expand All @@ -794,15 +999,33 @@ private void checkPutRequestHeader(HttpRequest request) {
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,
Expand Down
Loading