1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4 
5 package org.mozilla.gecko.sync.repositories.uploaders;
6 
7 import org.json.simple.JSONArray;
8 import org.mozilla.gecko.background.common.log.Logger;
9 import org.mozilla.gecko.sync.ExtendedJSONObject;
10 import org.mozilla.gecko.sync.HTTPFailureException;
11 import org.mozilla.gecko.sync.NonArrayJSONException;
12 import org.mozilla.gecko.sync.NonObjectJSONException;
13 import org.mozilla.gecko.sync.Server15RecordPostFailedException;
14 import org.mozilla.gecko.sync.Utils;
15 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
16 import org.mozilla.gecko.sync.net.SyncResponse;
17 import org.mozilla.gecko.sync.net.SyncStorageRequestDelegate;
18 import org.mozilla.gecko.sync.net.SyncStorageResponse;
19 
20 import java.util.ArrayList;
21 
22 class PayloadUploadDelegate implements SyncStorageRequestDelegate {
23     private static final String LOG_TAG = "PayloadUploadDelegate";
24 
25     private static final String KEY_BATCH = "batch";
26 
27     private final AuthHeaderProvider headerProvider;
28     private final PayloadDispatcher dispatcher;
29     private ArrayList<String> postedRecordGuids;
30     private final boolean isCommit;
31     private final boolean isLastPayload;
32 
PayloadUploadDelegate(AuthHeaderProvider headerProvider, PayloadDispatcher dispatcher, ArrayList<String> postedRecordGuids, boolean isCommit, boolean isLastPayload)33     PayloadUploadDelegate(AuthHeaderProvider headerProvider, PayloadDispatcher dispatcher, ArrayList<String> postedRecordGuids, boolean isCommit, boolean isLastPayload) {
34         this.headerProvider = headerProvider;
35         this.dispatcher = dispatcher;
36         this.postedRecordGuids = postedRecordGuids;
37         this.isCommit = isCommit;
38         this.isLastPayload = isLastPayload;
39     }
40 
41     @Override
getAuthHeaderProvider()42     public AuthHeaderProvider getAuthHeaderProvider() {
43         return headerProvider;
44     }
45 
46     @Override
ifUnmodifiedSince()47     public String ifUnmodifiedSince() {
48         final Long lastModified = dispatcher.batchWhiteboard.getLastModified();
49         if (lastModified == null) {
50             return null;
51         } else {
52             return Utils.millisecondsToDecimalSecondsString(lastModified);
53         }
54     }
55 
56     @Override
handleRequestSuccess(final SyncStorageResponse response)57     public void handleRequestSuccess(final SyncStorageResponse response) {
58         // First, do some sanity checking.
59         if (response.getStatusCode() != 200 && response.getStatusCode() != 202) {
60             handleRequestError(
61                 new IllegalStateException("handleRequestSuccess received a non-200/202 response: " + response.getStatusCode())
62             );
63             return;
64         }
65 
66         // We always expect to see a Last-Modified header. It's returned with every success response.
67         if (!response.httpResponse().containsHeader(SyncResponse.X_LAST_MODIFIED)) {
68             handleRequestError(
69                     new IllegalStateException("Response did not have a Last-Modified header")
70             );
71             return;
72         }
73 
74         // We expect to be able to parse the response as a JSON object.
75         final ExtendedJSONObject body;
76         try {
77             body = response.jsonObjectBody(); // jsonObjectBody() throws or returns non-null.
78         } catch (Exception e) {
79             Logger.error(LOG_TAG, "Got exception parsing POST success body.", e);
80             this.handleRequestError(e);
81             return;
82         }
83 
84         // If we got a 200, it could be either a non-batching result, or a batch commit.
85         // - if we're in a batching mode, we expect this to be a commit.
86         // If we got a 202, we expect there to be a token present in the response
87         if (response.getStatusCode() == 200 && dispatcher.batchWhiteboard.getToken() != null) {
88             if (dispatcher.batchWhiteboard.getInBatchingMode() && !isCommit) {
89                 handleRequestError(
90                         new IllegalStateException("Got 200 OK in batching mode, but this was not a commit payload")
91                 );
92                 return;
93             }
94         } else if (response.getStatusCode() == 202) {
95             if (!body.containsKey(KEY_BATCH)) {
96                 handleRequestError(
97                         new IllegalStateException("Batch response did not have a batch ID")
98                 );
99                 return;
100             }
101         }
102 
103         // With sanity checks out of the way, can now safely say if we're in a batching mode or not.
104         // We only do this once per session.
105         if (dispatcher.batchWhiteboard.getInBatchingMode() == null) {
106             dispatcher.setInBatchingMode(body.containsKey(KEY_BATCH));
107         }
108 
109         // Tell current batch about the token we've received.
110         // Throws if token changed after being set once, or if we got a non-null token after a commit.
111         try {
112             dispatcher.batchWhiteboard.setToken(body.getString(KEY_BATCH), isCommit);
113         } catch (BatchingUploader.BatchingUploaderException e) {
114             handleRequestError(e);
115             return;
116         }
117 
118         // Will throw if Last-Modified changed when it shouldn't have.
119         try {
120             // In non-batching mode, every time we receive a Last-Modified timestamp, we expect it
121             // to change since records are "committed" (become visible to other clients) on every
122             // payload.
123             // In batching mode, we only expect Last-Modified to change when we commit a batch.
124             dispatcher.batchWhiteboard.setLastModified(
125                     response.normalizedTimestampForHeader(SyncResponse.X_LAST_MODIFIED),
126                     isCommit || !dispatcher.batchWhiteboard.getInBatchingMode()
127             );
128         } catch (BatchingUploader.BatchingUploaderException e) {
129             handleRequestError(e);
130             return;
131         }
132 
133         // All looks good up to this point, let's process success and failed arrays.
134         JSONArray success;
135         try {
136             success = body.getArray("success");
137         } catch (NonArrayJSONException e) {
138             handleRequestError(e);
139             return;
140         }
141 
142         if (success != null && !success.isEmpty()) {
143             Logger.trace(LOG_TAG, "Successful records: " + success.toString());
144             dispatcher.batchWhiteboard.recordsSucceeded(success.size());
145         }
146         // GC
147         success = null;
148 
149         ExtendedJSONObject failed;
150         try {
151             failed = body.getObject("failed");
152         } catch (NonObjectJSONException e) {
153             handleRequestError(e);
154             return;
155         }
156 
157         if (failed != null && !failed.object.isEmpty()) {
158             Logger.debug(LOG_TAG, "Failed records: " + failed.object.toString());
159             for (String guid : failed.keySet()) {
160                 dispatcher.recordFailed(guid);
161             }
162             dispatcher.payloadFailed(new Server15RecordPostFailedException());
163             return;
164         }
165         // GC
166         failed = null;
167 
168         // And we're done! Let uploader finish up.
169         dispatcher.payloadSucceeded(
170                 response,
171                 isCommit,
172                 isLastPayload
173         );
174 
175         if (isCommit && !isLastPayload) {
176             dispatcher.prepareForNextBatch();
177         }
178     }
179 
180     @Override
handleRequestFailure(final SyncStorageResponse response)181     public void handleRequestFailure(final SyncStorageResponse response) {
182         if (response.getStatusCode() == 412) {
183             dispatcher.concurrentModificationDetected();
184         } else {
185             this.handleRequestError(new HTTPFailureException(response));
186         }
187     }
188 
189     @Override
handleRequestError(Exception e)190     public void handleRequestError(Exception e) {
191         for (String guid : postedRecordGuids) {
192             dispatcher.recordFailed(e, guid);
193         }
194         // GC
195         postedRecordGuids = null;
196         dispatcher.payloadFailed(e);
197     }
198 }