diff --git a/parse/src/main/java/com/parse/ParseException.java b/parse/src/main/java/com/parse/ParseException.java index 5dc3b5f9..972a8b4a 100644 --- a/parse/src/main/java/com/parse/ParseException.java +++ b/parse/src/main/java/com/parse/ParseException.java @@ -102,6 +102,8 @@ public class ParseException extends Exception { public static final int FILE_DELETE_ERROR = 153; /** Error code indicating that the application has exceeded its request limit. */ public static final int REQUEST_LIMIT_EXCEEDED = 155; + /** Error code indicating that the request was a duplicate and has been discarded due to idempotency rules. */ + public static final int DUPLICATE_REQUEST = 159; /** Error code indicating that the provided event name is invalid. */ public static final int INVALID_EVENT_NAME = 160; /** Error code indicating that the username is missing or empty. */ diff --git a/parse/src/main/java/com/parse/ParseRESTCommand.java b/parse/src/main/java/com/parse/ParseRESTCommand.java index 60cd1c59..55b9114a 100644 --- a/parse/src/main/java/com/parse/ParseRESTCommand.java +++ b/parse/src/main/java/com/parse/ParseRESTCommand.java @@ -20,6 +20,8 @@ import java.util.Collections; import java.util.Iterator; import java.util.Map; +import java.util.UUID; + import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -35,6 +37,7 @@ class ParseRESTCommand extends ParseRequest { /* package */ static final String HEADER_OS_VERSION = "X-Parse-OS-Version"; /* package */ static final String HEADER_INSTALLATION_ID = "X-Parse-Installation-Id"; + /* package */ static final String HEADER_REQUEST_ID = "X-Parse-Request-Id"; /* package */ static final String USER_AGENT = "User-Agent"; private static final String HEADER_SESSION_TOKEN = "X-Parse-Session-Token"; private static final String HEADER_MASTER_KEY = "X-Parse-Master-Key"; @@ -49,6 +52,7 @@ class ParseRESTCommand extends ParseRequest { /* package */ String httpPath; private String installationId; private String operationSetUUID; + private final String requestId = UUID.randomUUID().toString(); private String localId; public ParseRESTCommand( @@ -215,6 +219,7 @@ protected void addAdditionalHeaders(ParseHttpRequest.Builder requestBuilder) { if (masterKey != null) { requestBuilder.addHeader(HEADER_MASTER_KEY, masterKey); } + requestBuilder.addHeader(HEADER_REQUEST_ID, requestId); } @Override diff --git a/parse/src/test/java/com/parse/ParseRESTUserCommandTest.java b/parse/src/test/java/com/parse/ParseRESTUserCommandTest.java index a735465b..517b9716 100644 --- a/parse/src/test/java/com/parse/ParseRESTUserCommandTest.java +++ b/parse/src/test/java/com/parse/ParseRESTUserCommandTest.java @@ -55,7 +55,7 @@ public void testGetCurrentUserCommand() { @Test public void testLogInUserCommand() throws Exception { ParseRESTUserCommand command = - ParseRESTUserCommand.logInUserCommand("userName", "password", true); + ParseRESTUserCommand.logInUserCommand("userName", "password", true); assertEquals("login", command.httpPath); assertEquals(ParseHttpRequest.Method.GET, command.method); @@ -68,7 +68,7 @@ public void testLogInUserCommand() throws Exception { @Test public void testResetPasswordResetCommand() throws Exception { ParseRESTUserCommand command = - ParseRESTUserCommand.resetPasswordResetCommand("test@parse.com"); + ParseRESTUserCommand.resetPasswordResetCommand("test@parse.com"); assertEquals("requestPasswordReset", command.httpPath); assertEquals(ParseHttpRequest.Method.POST, command.method); @@ -82,7 +82,7 @@ public void testSignUpUserCommand() throws Exception { JSONObject parameters = new JSONObject(); parameters.put("key", "value"); ParseRESTUserCommand command = - ParseRESTUserCommand.signUpUserCommand(parameters, "sessionToken", true); + ParseRESTUserCommand.signUpUserCommand(parameters, "sessionToken", true); assertEquals("users", command.httpPath); assertEquals(ParseHttpRequest.Method.POST, command.method); @@ -96,7 +96,7 @@ public void testServiceLogInUserCommandWithParameters() throws Exception { JSONObject parameters = new JSONObject(); parameters.put("key", "value"); ParseRESTUserCommand command = - ParseRESTUserCommand.serviceLogInUserCommand(parameters, "sessionToken", true); + ParseRESTUserCommand.serviceLogInUserCommand(parameters, "sessionToken", true); assertEquals("users", command.httpPath); assertEquals(ParseHttpRequest.Method.POST, command.method); @@ -110,7 +110,7 @@ public void testServiceLogInUserCommandWithAuthType() throws Exception { Map facebookAuthData = new HashMap<>(); facebookAuthData.put("token", "test"); ParseRESTUserCommand command = - ParseRESTUserCommand.serviceLogInUserCommand("facebook", facebookAuthData, true); + ParseRESTUserCommand.serviceLogInUserCommand("facebook", facebookAuthData, true); assertEquals("users", command.httpPath); assertEquals(ParseHttpRequest.Method.POST, command.method); @@ -132,7 +132,7 @@ public void testAddAdditionalHeaders() throws Exception { JSONObject parameters = new JSONObject(); parameters.put("key", "value"); ParseRESTUserCommand command = - ParseRESTUserCommand.signUpUserCommand(parameters, "sessionToken", true); + ParseRESTUserCommand.signUpUserCommand(parameters, "sessionToken", true); ParseHttpRequest.Builder requestBuilder = new ParseHttpRequest.Builder(); command.addAdditionalHeaders(requestBuilder); @@ -153,11 +153,11 @@ public void testOnResponseAsync() { int statusCode = 200; ParseHttpResponse response = - new ParseHttpResponse.Builder() - .setContent(new ByteArrayInputStream(content.getBytes())) - .setContentType(contentType) - .setStatusCode(statusCode) - .build(); + new ParseHttpResponse.Builder() + .setContent(new ByteArrayInputStream(content.getBytes())) + .setContentType(contentType) + .setStatusCode(statusCode) + .build(); command.onResponseAsync(response, null); assertEquals(200, command.getStatusCode()); diff --git a/parse/src/test/java/com/parse/ParseRequestTest.java b/parse/src/test/java/com/parse/ParseRequestTest.java index f3ec6247..1d833d1c 100644 --- a/parse/src/test/java/com/parse/ParseRequestTest.java +++ b/parse/src/test/java/com/parse/ParseRequestTest.java @@ -10,8 +10,10 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -24,8 +26,11 @@ import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; +import java.net.URL; import java.util.LinkedList; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + import org.junit.After; import org.junit.AfterClass; import org.junit.Before; @@ -121,6 +126,25 @@ public void testDownloadProgress() throws Exception { assertProgressCompletedSuccessfully(downloadProgressCallback); } + @Test + public void testIdempotencyLogic() throws Exception { + ParseHttpClient mockHttpClient = mock(ParseHttpClient.class); + AtomicReference requestIdAtomicReference = new AtomicReference<>(); + when(mockHttpClient.execute(argThat(argument -> { + assertNotNull(argument.getHeader(ParseRESTCommand.HEADER_REQUEST_ID)); + if (requestIdAtomicReference.get() == null) requestIdAtomicReference.set(argument.getHeader(ParseRESTCommand.HEADER_REQUEST_ID)); + assertEquals(argument.getHeader(ParseRESTCommand.HEADER_REQUEST_ID), requestIdAtomicReference.get()); + return true; + }))).thenThrow(new IOException()); + + ParseRESTCommand.server = new URL("http://parse.com"); + ParseRESTCommand command = new ParseRESTCommand.Builder().build(); + Task task = command.executeAsync(mockHttpClient).makeVoid(); + task.waitForCompletion(); + + verify(mockHttpClient, times(5)).execute(any(ParseHttpRequest.class)); + } + private static class TestProgressCallback implements ProgressCallback { final List history = new LinkedList<>();