/* * Copyright (c) 2015-present, Parse, LLC. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. */ package com.parse; import com.parse.http.ParseHttpBody; import com.parse.http.ParseHttpRequest; import com.parse.http.ParseHttpResponse; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.json.JSONStringer; import java.io.IOException; import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.Map; import bolts.Task; /** * A helper object to send requests to the server. */ /** package */ class ParseRESTCommand extends ParseRequest<JSONObject> { /* package */ static final String HEADER_APPLICATION_ID = "X-Parse-Application-Id"; /* package */ static final String HEADER_CLIENT_KEY = "X-Parse-Client-Key"; /* package */ static final String HEADER_CLIENT_VERSION = "X-Parse-Client-Version"; /* package */ static final String HEADER_APP_BUILD_VERSION = "X-Parse-App-Build-Version"; /* package */ static final String HEADER_APP_DISPLAY_VERSION = "X-Parse-App-Display-Version"; /* 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 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"; private static final String PARAMETER_METHOD_OVERRIDE = "_method"; // Set via Parse.initialize(Configuration) /* package */ static URL server = null; private static LocalIdManager getLocalIdManager() { return ParseCorePlugins.getInstance().getLocalIdManager(); } /* package */ static abstract class Init<T extends Init<T>> { private String sessionToken; private String installationId; public String masterKey; private ParseHttpRequest.Method method = ParseHttpRequest.Method.GET; private String httpPath; private JSONObject jsonParameters; private String operationSetUUID; private String localId; /* package */ abstract T self(); public T sessionToken(String sessionToken) { this.sessionToken = sessionToken; return self(); } public T installationId(String installationId) { this.installationId = installationId; return self(); } public T masterKey(String masterKey) { this.masterKey = masterKey; return self(); } public T method(ParseHttpRequest.Method method) { this.method = method; return self(); } public T httpPath(String httpPath) { this.httpPath = httpPath; return self(); } public T jsonParameters(JSONObject jsonParameters) { this.jsonParameters = jsonParameters; return self(); } public T operationSetUUID(String operationSetUUID) { this.operationSetUUID = operationSetUUID; return self(); } public T localId(String localId) { this.localId = localId; return self(); } } public static class Builder extends Init<Builder> { @Override /* package */ Builder self() { return this; } public ParseRESTCommand build() { return new ParseRESTCommand(this); } } // Headers private final String sessionToken; private String installationId; public String masterKey; /* package */ String httpPath; /* package */ final JSONObject jsonParameters; private String operationSetUUID; private String localId; public ParseRESTCommand( String httpPath, ParseHttpRequest.Method httpMethod, Map<String, ?> parameters, String sessionToken) { this( httpPath, httpMethod, parameters != null ? (JSONObject) NoObjectsEncoder.get().encode(parameters) : null, sessionToken); } public ParseRESTCommand( String httpPath, ParseHttpRequest.Method httpMethod, JSONObject jsonParameters, String sessionToken) { this(httpPath, httpMethod, jsonParameters, null, sessionToken); } private ParseRESTCommand( String httpPath, ParseHttpRequest.Method httpMethod, JSONObject jsonParameters, String localId, String sessionToken) { super(httpMethod, createUrl(httpPath)); this.httpPath = httpPath; this.jsonParameters = jsonParameters; this.localId = localId; this.sessionToken = sessionToken; } /* package */ ParseRESTCommand(Init<?> builder) { super(builder.method, createUrl(builder.httpPath)); sessionToken = builder.sessionToken; installationId = builder.installationId; masterKey = builder.masterKey; httpPath = builder.httpPath; jsonParameters = builder.jsonParameters; operationSetUUID = builder.operationSetUUID; localId = builder.localId; } public static ParseRESTCommand fromJSONObject(JSONObject jsonObject) { String httpPath = jsonObject.optString("httpPath"); ParseHttpRequest.Method httpMethod = ParseHttpRequest.Method.fromString(jsonObject.optString("httpMethod")); String sessionToken = jsonObject.optString("sessionToken", null); String localId = jsonObject.optString("localId", null); JSONObject jsonParameters = jsonObject.optJSONObject("parameters"); return new ParseRESTCommand(httpPath, httpMethod, jsonParameters, localId, sessionToken); } // TODO(grantland): But we don't disable retries by default... /* package */ void enableRetrying() { setMaxRetries(DEFAULT_MAX_RETRIES); } private static String createUrl(String httpPath) { // We send all parameters for GET/HEAD/DELETE requests in a post body, // so no need to worry about query parameters here. if (httpPath == null) { return server.toString(); } try { return new URL(server, httpPath).toString(); } catch (MalformedURLException ex) { throw new RuntimeException(ex); } } protected void addAdditionalHeaders(ParseHttpRequest.Builder requestBuilder) { if (installationId != null) { requestBuilder.addHeader(HEADER_INSTALLATION_ID, installationId); } if (sessionToken != null) { requestBuilder.addHeader(HEADER_SESSION_TOKEN, sessionToken); } if (masterKey != null) { requestBuilder.addHeader(HEADER_MASTER_KEY, masterKey); } } @Override protected ParseHttpRequest newRequest( ParseHttpRequest.Method method, String url, ProgressCallback uploadProgressCallback) { ParseHttpRequest request; if (jsonParameters != null && method != ParseHttpRequest.Method.POST && method != ParseHttpRequest.Method.PUT) { // The request URI may be too long to include parameters in the URI. // To avoid this problem we send the parameters in a POST request json-encoded body // and add a http method override parameter in newBody. request = super.newRequest(ParseHttpRequest.Method.POST, url, uploadProgressCallback); } else { request = super.newRequest(method, url, uploadProgressCallback); } ParseHttpRequest.Builder requestBuilder = new ParseHttpRequest.Builder(request); addAdditionalHeaders(requestBuilder); return requestBuilder.build(); } @Override protected ParseHttpBody newBody(ProgressCallback uploadProgressCallback) { if (jsonParameters == null) { String message = String.format("Trying to execute a %s command without body parameters.", method.toString()); throw new IllegalArgumentException(message); } try { JSONObject parameters = jsonParameters; if (method == ParseHttpRequest.Method.GET || method == ParseHttpRequest.Method.DELETE) { // The request URI may be too long to include parameters in the URI. // To avoid this problem we send the parameters in a POST request json-encoded body // and add a http method override parameter. parameters = new JSONObject(jsonParameters.toString()); parameters.put(PARAMETER_METHOD_OVERRIDE, method.toString()); } return new ParseByteArrayHttpBody(parameters.toString(), "application/json"); } catch (Exception e) { throw new RuntimeException(e.getMessage()); } } @Override public Task<JSONObject> executeAsync( final ParseHttpClient client, final ProgressCallback uploadProgressCallback, final ProgressCallback downloadProgressCallback, final Task<Void> cancellationToken) { resolveLocalIds(); return super.executeAsync( client, uploadProgressCallback, downloadProgressCallback, cancellationToken); } @Override protected Task<JSONObject> onResponseAsync(ParseHttpResponse response, ProgressCallback downloadProgressCallback) { String content; InputStream responseStream = null; try { responseStream = response.getContent(); content = new String(ParseIOUtils.toByteArray(responseStream)); } catch (IOException e) { return Task.forError(e); } finally { ParseIOUtils.closeQuietly(responseStream); } // We need to check for errors differently in /1/ than /2/ since object data in /2/ was // encapsulated in "data" and everything was 200, but /2/ everything is in the root JSON, // but errors are status 4XX. // See https://quip.com/4pbbA9HbOPjQ int statusCode = response.getStatusCode(); if (statusCode >= 200 && statusCode < 600) { // Assume 3XX is handled by http library JSONObject json; try { json = new JSONObject(content); if (statusCode >= 400 && statusCode < 500) { // 4XX return Task.forError(newPermanentException(json.optInt("code"), json.optString("error"))); } else if (statusCode >= 500) { // 5XX return Task.forError(newTemporaryException(json.optInt("code"), json.optString("error"))); } return Task.forResult(json); } catch (JSONException e) { return Task.forError(newTemporaryException("bad json response", e)); } } return Task.forError(newPermanentException(ParseException.OTHER_CAUSE, content)); } // Creates a somewhat-readable string that uniquely identifies this command. public String getCacheKey() { String json; if (jsonParameters != null) { try { json = toDeterministicString(jsonParameters); } catch (JSONException e) { throw new RuntimeException(e.getMessage()); } } else { json = ""; } // Include the session token in the cache in order to avoid mixing permissions. if (sessionToken != null) { json += sessionToken; } return String.format( "ParseRESTCommand.%s.%s.%s", method.toString(), ParseDigestUtils.md5(httpPath), ParseDigestUtils.md5(json) ); } // Encodes the object to JSON, but ensures that JSONObjects // and nested JSONObjects are encoded with keys in alphabetical order. /** package */ static String toDeterministicString(Object o) throws JSONException { JSONStringer stringer = new JSONStringer(); addToStringer(stringer, o); return stringer.toString(); } // Uses the provided JSONStringer to encode this object to JSON, but ensures that JSONObjects and // nested JSONObjects are encoded with keys in alphabetical order. private static void addToStringer(JSONStringer stringer, Object o) throws JSONException { if (o instanceof JSONObject) { stringer.object(); JSONObject object = (JSONObject) o; Iterator<String> keyIterator = object.keys(); ArrayList<String> keys = new ArrayList<>(); while (keyIterator.hasNext()) { keys.add(keyIterator.next()); } Collections.sort(keys); for (String key : keys) { stringer.key(key); addToStringer(stringer, object.opt(key)); } stringer.endObject(); return; } if (o instanceof JSONArray) { JSONArray array = (JSONArray) o; stringer.array(); for (int i = 0; i < array.length(); ++i) { addToStringer(stringer, array.get(i)); } stringer.endArray(); return; } stringer.value(o); } /* package */ static boolean isValidCommandJSONObject(JSONObject jsonObject) { return jsonObject.has("httpPath"); } // This function checks whether a json object is a valid /2 ParseCommand json. /* package */ static boolean isValidOldFormatCommandJSONObject(JSONObject jsonObject) { return jsonObject.has("op"); } public JSONObject toJSONObject() { JSONObject jsonObject = new JSONObject(); try { if (httpPath != null) { jsonObject.put("httpPath", httpPath); } jsonObject.put("httpMethod", method.toString()); if (jsonParameters != null) { jsonObject.put("parameters", jsonParameters); } if (sessionToken != null) { jsonObject.put("sessionToken", sessionToken); } if (localId != null) { jsonObject.put("localId", localId); } } catch (JSONException e) { throw new RuntimeException(e.getMessage()); } return jsonObject; } public String getSessionToken() { return sessionToken; } public String getOperationSetUUID() { return operationSetUUID; } /* package */ void setOperationSetUUID(String operationSetUUID) { this.operationSetUUID = operationSetUUID; } public void setLocalId(String localId) { this.localId = localId; } public String getLocalId() { return localId; } /** * If this was the second save on a new object while offline, then its objectId wasn't yet set * when the command was created, so it would have been considered a "create". But if the first * save succeeded, then there is an objectId now, and it will be mapped to the localId for this * command's result. If so, change the "create" operation to an "update", and add the objectId to * the command. */ private void maybeChangeServerOperation() throws JSONException { if (localId != null) { String objectId = getLocalIdManager().getObjectId(localId); if (objectId != null) { localId = null; httpPath += String.format("/%s", objectId); url = createUrl(httpPath); if (httpPath.startsWith("classes") && method == ParseHttpRequest.Method.POST) { method = ParseHttpRequest.Method.PUT; } } } } public void resolveLocalIds() { try { ArrayList<JSONObject> localPointers = new ArrayList<>(); getLocalPointersIn(jsonParameters, localPointers); for (JSONObject pointer : localPointers) { String localId = (String) pointer.get("localId"); String objectId = getLocalIdManager().getObjectId(localId); if (objectId == null) { throw new IllegalStateException( "Tried to serialize a command referencing a new, unsaved object."); } pointer.put("objectId", objectId); pointer.remove("localId"); } maybeChangeServerOperation(); } catch (JSONException e) { // Well, nothing to do here... } } /** * Finds all of the local ids in this command and increments their retain counts in the on-disk * store. This should be called immediately before serializing the command to disk, so that we * know we might need to resolve these local ids at some point in the future. */ public void retainLocalIds() { if (localId != null) { getLocalIdManager().retainLocalIdOnDisk(localId); } try { ArrayList<JSONObject> localPointers = new ArrayList<>(); getLocalPointersIn(jsonParameters, localPointers); for (JSONObject pointer : localPointers) { String localId = (String) pointer.get("localId"); getLocalIdManager().retainLocalIdOnDisk(localId); } } catch (JSONException e) { // Well, nothing to do here... } } /** * Finds all of the local ids in this command and decrements their retain counts in the on-disk * store. This should be called when removing a serialized command from the disk, when we know * that we will never need to resolve these local ids for this command again in the future. */ public void releaseLocalIds() { if (localId != null) { getLocalIdManager().releaseLocalIdOnDisk(localId); } try { ArrayList<JSONObject> localPointers = new ArrayList<>(); getLocalPointersIn(jsonParameters, localPointers); for (JSONObject pointer : localPointers) { String localId = (String) pointer.get("localId"); getLocalIdManager().releaseLocalIdOnDisk(localId); } } catch (JSONException e) { // Well, nothing to do here... } } protected static void getLocalPointersIn(Object container, ArrayList<JSONObject> localPointers) throws JSONException { if (container instanceof JSONObject) { JSONObject object = (JSONObject) container; if ("Pointer".equals(object.opt("__type")) && object.has("localId")) { localPointers.add((JSONObject) container); return; } Iterator<String> keyIterator = object.keys(); while (keyIterator.hasNext()) { String key = keyIterator.next(); getLocalPointersIn(object.get(key), localPointers); } } if (container instanceof JSONArray) { JSONArray array = (JSONArray) container; for (int i = 0; i < array.length(); ++i) { getLocalPointersIn(array.get(i), localPointers); } } } }