/*
* Copyright 2011 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.waveprotocol.box.waveimport.google;
import com.google.common.base.Charsets;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.waveprotocol.wave.model.id.WaveId;
import org.waveprotocol.wave.model.id.WaveletId;
import org.waveprotocol.wave.model.id.WaveletName;
import org.waveprotocol.wave.model.util.ValueUtils;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
import org.apache.commons.httpclient.Header;
import org.apache.commons.httpclient.methods.ByteArrayRequestEntity;
import org.apache.commons.httpclient.methods.PostMethod;
import org.waveprotocol.box.waveimport.google.oauth.OAuthedFetchService;
import org.waveprotocol.box.waveimport.google.oauth.OAuthedFetchService.TokenRefreshNeededDetector;
/**
* Simple interface to Google Wave's active robot API.
*
* We don't use the "official" Wave robot API library since it doesn't support
* some of the APIs that we need (raw snapshot/delta export), and implementing
* what we need is easy anyway. The main difficulty is OAuth, but we already
* have code for that.
*
* @author ohler@google.com (Christian Ohler)
* @author A. Kaplanov
*/
public class RobotApi {
public interface Factory {
RobotApi create(@Assisted String baseUrl);
}
@SuppressWarnings("unused")
private static final Logger log = Logger.getLogger(RobotApi.class.getName());
private final OAuthedFetchService fetch;
private final String baseUrl;
@Inject
public RobotApi(OAuthedFetchService fetch,
@Assisted String baseUrl) {
this.fetch = fetch;
this.baseUrl = baseUrl;
}
private static final String OP_ID = "op_id";
private static final String ROBOT_API_METHOD_FETCH_WAVE = "wave.robot.fetchWave";
private static final String ROBOT_API_METHOD_SEARCH = "wave.robot.search";
private static final String EXPECTED_CONTENT_TYPE = "application/json; charset=UTF-8";
private final TokenRefreshNeededDetector robotErrorCode401Detector =
new TokenRefreshNeededDetector() {
@Override public boolean refreshNeeded(int responseCode, Header[] responseHeaders, byte[] responseContent) throws IOException {
if (responseCode == 401) {
return true;
}
if (!EXPECTED_CONTENT_TYPE.equals(fetch.getSingleHeader(responseHeaders, "Content-Type"))) {
return false;
}
JSONObject result = parseJsonResponseBody(responseHeaders, responseContent);
try {
if (result.has("error")) {
JSONObject error = result.getJSONObject("error");
return error.has("code") && error.getInt("code") == 401;
} else {
return false;
}
} catch (JSONException e) {
throw new RuntimeException("JSONException parsing response: " + result, e);
}
}
};
private JSONObject parseJsonResponseBody(Header[] responseHeaders, byte[] responseContent) throws IOException {
// The response looks like this:
// [{"id":"op_id", "data":X}]
// We return the single item in this array.
String body = fetch.getUtf8ResponseBody(responseHeaders, responseContent, EXPECTED_CONTENT_TYPE);
try {
JSONArray items = new JSONArray(body);
if (items.length() != 1) {
throw new RuntimeException("Unexpected length: " + items.length() + ": " + items);
}
JSONObject item = items.getJSONObject(0);
if (!OP_ID.equals(item.getString("id"))) {
throw new RuntimeException("Unexpected id: " + item);
}
return item;
} catch (JSONException e) {
throw new RuntimeException("JSONException parsing response: " + body, e);
}
}
private JSONObject callRobotApi(String method, Map<String, Object> params) throws IOException {
JSONArray ops = new JSONArray();
try {
JSONObject jsonParams = new JSONObject();
for (Map.Entry<String, Object> e : params.entrySet()) {
jsonParams.put(e.getKey(), e.getValue());
}
JSONObject op = new JSONObject();
op.put("params", jsonParams);
op.put("method", method);
op.put("id", OP_ID);
ops.put(op);
} catch (JSONException e) {
throw new RuntimeException("Failed to construct JSON object", e);
}
PostMethod req = new PostMethod(baseUrl);
log.info("payload=" + ops);
req.setRequestHeader("Content-Type", "application/json; charset=UTF-8");
req.setRequestEntity(new ByteArrayRequestEntity(ops.toString().getBytes(Charsets.UTF_8)));
System.out.println("req: " + ops.toString());
fetch.fetch(req, robotErrorCode401Detector);
JSONObject result = parseJsonResponseBody(req.getResponseHeaders(), req.getResponseBody());
log.info("result=" + ValueUtils.abbrev("" + result, 500));
try {
if (result.has("error")) {
log.warning("Error result: " + result);
JSONObject error = result.getJSONObject("error");
throw new RuntimeException("Error from robot API: " + error);
} else if (result.has("data")) {
JSONObject data = result.getJSONObject("data");
if (data.length() == 0) {
// Apparently, the server often sends {"id":"op_id", "data":{}} when
// something went wrong on the server side, so we translate that to an
// IOException.
throw new IOException("Robot API response looks like an error: " + result);
} else {
return data;
}
} else {
throw new RuntimeException("Result has neither error nor data: " + result);
}
} catch (JSONException e) {
throw new RuntimeException("JSONException parsing result: " + result, e);
}
}
private JSONObject callRobotApi1(String method, Map<String, Object> params) throws IOException {
JSONArray ops = new JSONArray();
try {
JSONObject jsonParams = new JSONObject();
for (Map.Entry<String, Object> e : params.entrySet()) {
jsonParams.put(e.getKey(), e.getValue());
}
JSONObject op = new JSONObject();
op.put("params", jsonParams);
op.put("method", method);
op.put("id", OP_ID);
ops.put(op);
} catch (JSONException e) {
throw new RuntimeException("Failed to construct JSON object", e);
}
PostMethod req = new PostMethod(baseUrl);
log.info("payload=" + ops);
req.setRequestHeader("Content-Type", "application/json; charset=UTF-8");
req.setRequestEntity(new ByteArrayRequestEntity(ops.toString().getBytes(Charsets.UTF_8)));
System.out.println("req: " + ops.toString());
fetch.fetch(req, robotErrorCode401Detector);
JSONObject result = parseJsonResponseBody(req.getRequestHeaders(), req.getResponseBody());
log.info("result=" + ValueUtils.abbrev("" + result, 500));
return result;
}
private Map<String, Object> getFetchWaveParamMap(WaveletName waveletName, Object... extraParams) {
Preconditions.checkArgument(extraParams.length % 2 == 0,
"extraParams must come in pairs: %s", extraParams);
ImmutableMap.Builder<String, Object> b = ImmutableMap.builder();
b.put("waveId", waveletName.waveId.serialise());
b.put("waveletId", waveletName.waveletId.serialise());
for (int i = 0; i < extraParams.length; i += 2) {
b.put((String) extraParams[i], extraParams[i + 1]);
}
return b.build();
}
/**
* Gets the list of wavelets in a wave that are visible the user.
*/
public List<WaveletId> getWaveView(WaveId waveId) throws IOException {
JSONObject resp = callRobotApi(ROBOT_API_METHOD_FETCH_WAVE,
ImmutableMap.<String, Object>of("waveId", waveId.serialise(), "listWavelets", true));
try {
JSONArray ids = resp.getJSONArray("waveletIds");
ImmutableList.Builder<WaveletId> out = ImmutableList.builder();
for (int i = 0; i < ids.length(); i++) {
out.add(WaveletId.deserialise(ids.getString(i)));
}
List<WaveletId> view = out.build();
log.info("getWaveView(" + waveId + ") = " + view);
return view;
} catch (JSONException e) {
throw new RuntimeException("Failed to parse listWavelets response: " + resp, e);
}
}
/**
* Fetch wave with deltas
*/
public JSONObject fetchWaveWithDeltas(WaveId waveId, WaveletId waveletId, long fromVersion) throws IOException, JSONException {
return callRobotApi1(ROBOT_API_METHOD_FETCH_WAVE,
getFetchWaveParamMap(WaveletName.of(waveId, waveletId), "rawDeltasFromVersion", fromVersion));
}
/**
* Searches the user's waves. Returns at most {@code maxResults} results,
* starting with the {@code startIndex}-th result (0-based index).
*
* Note: Google Wave's search feature may limit the overall set of result for
* any given query to the first N hits (for some N, perhaps 300), regardless
* of {@code startIndex} and {@code maxResults}, so don't rely on these to
* iterate over all waves.
*/
public List<RobotSearchDigest> search(String query, int startIndex, int maxResults)
throws IOException {
log.info("search(" + query + ", " + startIndex + ", " + maxResults + ")");
JSONObject response = callRobotApi(ROBOT_API_METHOD_SEARCH,
ImmutableMap.<String, Object>of("query", query,
"index", startIndex,
"numResults", maxResults));
System.out.println("gson :" + response.toString());
ImmutableList.Builder<RobotSearchDigest> digests = ImmutableList.builder();
try {
JSONObject results = response.getJSONObject("searchResults");
try {
if (results.getInt("numResults") != results.getJSONArray("digests").length()) {
throw new RuntimeException("Mismatched numResults and digests array length: "
+ results.getInt("numResults") + " vs. " + results.getJSONArray("digests"));
}
JSONArray rawDigests = results.getJSONArray("digests");
for (int i = 0; i < rawDigests.length(); i++) {
JSONObject rawDigest = rawDigests.getJSONObject(i);
try {
RobotSearchDigest digest = new RobotSearchDigestGsonImpl();
digest.setWaveId(WaveId.deserialise(rawDigest.getString("waveId")).serialise());
JSONArray rawParticipants = rawDigest.getJSONArray("participants");
for (int j = 0; j < rawParticipants.length(); j++) {
digest.addParticipant(rawParticipants.getString(j));
}
digest.setTitle(rawDigest.getString("title"));
digest.setSnippet(rawDigest.getString("snippet"));
digest.setLastModifiedMillis(rawDigest.getLong("lastModified"));
digest.setBlipCount(rawDigest.getInt("blipCount"));
digest.setUnreadBlipCount(rawDigest.getInt("unreadCount"));
digests.add(digest);
} catch (JSONException e) {
throw new RuntimeException("Failed to parse search digest: " + rawDigest, e);
}
}
} catch (JSONException e) {
throw new RuntimeException("Failed to parse search results: " + results, e);
}
} catch (JSONException e) {
throw new RuntimeException("Failed to parse search response: " + response, e);
}
return digests.build();
}
}