/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.apache.shindig.protocol;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.shindig.auth.SecurityToken;
import org.apache.shindig.common.servlet.HttpUtil;
import org.apache.shindig.common.util.JsonConversionUtil;
import org.apache.shindig.protocol.multipart.FormDataItem;
import org.apache.shindig.protocol.multipart.MultipartFormParser;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.inject.Inject;
import com.google.inject.name.Named;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.Writer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Future;
/**
* JSON-RPC handler servlet.
*/
public class JsonRpcServlet extends ApiServlet {
public static final Set<String> ALLOWED_CONTENT_TYPES =
new ImmutableSet.Builder<String>().addAll(ContentTypes.ALLOWED_JSON_CONTENT_TYPES)
.addAll(ContentTypes.ALLOWED_MULTIPART_CONTENT_TYPES).build();
/**
* In a multipart request, the form item with field name "request" will contain the
* actual request, per the proposed Opensocial 0.9 specification.
*/
public static final String REQUEST_PARAM = "request";
private MultipartFormParser formParser;
@Inject
void setMultipartFormParser(MultipartFormParser formParser) {
this.formParser = formParser;
}
private String jsonRpcResultField = "result";
private boolean jsonRpcBothFields = false;
@Inject
void setJsonRpcResultField(@Named("shindig.json-rpc.result-field")String jsonRpcResultField) {
this.jsonRpcResultField = jsonRpcResultField;
jsonRpcBothFields = "both".equals(jsonRpcResultField);
}
@Override
protected void service(HttpServletRequest servletRequest, HttpServletResponse servletResponse)
throws IOException {
setCharacterEncodings(servletRequest, servletResponse);
servletResponse.setContentType(ContentTypes.OUTPUT_JSON_CONTENT_TYPE);
// only GET/POST
String method = servletRequest.getMethod();
if (!("GET".equals(method) || "POST".equals(method))) {
sendError(servletResponse,
new ResponseItem(HttpServletResponse.SC_BAD_REQUEST, "Only POST/GET Allowed"));
return;
}
SecurityToken token = getSecurityToken(servletRequest);
if (token == null) {
sendSecurityError(servletResponse);
return;
}
HttpUtil.setCORSheader(servletResponse, containerConfig.<String>getList(token.getContainer(), "gadgets.parentOrigins"));
try {
String content = null;
String callback = null; // for JSONP
Map<String,FormDataItem> formData = Maps.newHashMap();
// Get content or deal with JSON-RPC GET
if ("POST".equals(method)) {
content = getPostContent(servletRequest, formData);
} else if (HttpUtil.isJSONP(servletRequest)) {
content = servletRequest.getParameter("request");
callback = servletRequest.getParameter("callback");
} else {
// GET request, fromRequest() creates the json objects directly.
JSONObject request = JsonConversionUtil.fromRequest(servletRequest);
if (request != null) {
dispatch(request, formData, servletRequest, servletResponse, token, null);
return;
}
}
if (content == null) {
sendError(servletResponse, new ResponseItem(HttpServletResponse.SC_BAD_REQUEST, "No content specified"));
return;
}
if (isContentJsonBatch(content)) {
JSONArray batch = new JSONArray(content);
dispatchBatch(batch, formData, servletRequest, servletResponse, token, callback);
} else {
JSONObject request = new JSONObject(content);
dispatch(request, formData, servletRequest, servletResponse, token, callback);
}
return;
} catch (JSONException je) {
sendJsonParseError(je, servletResponse);
} catch (IllegalArgumentException e) {
// a bad jsonp request..
sendBadRequest(e, servletResponse);
} catch (ContentTypes.InvalidContentTypeException icte) {
sendBadRequest(icte, servletResponse);
}
}
protected String getPostContent(HttpServletRequest request, Map<String,FormDataItem> formItems)
throws ContentTypes.InvalidContentTypeException, IOException {
String content = null;
ContentTypes.checkContentTypes(ALLOWED_CONTENT_TYPES, request.getContentType());
if (formParser.isMultipartContent(request)) {
for (FormDataItem item : formParser.parse(request)) {
if (item.isFormField() && REQUEST_PARAM.equals(item.getFieldName()) && content == null) {
// As per spec, in case of a multipart/form-data content, there will be one form field
// with field name as "request". It will contain the json request. Any further form
// field or file item will not be parsed out, but will be exposed via getFormItem
// method of RequestItem.
if (!StringUtils.isEmpty(item.getContentType())) {
ContentTypes.checkContentTypes(ContentTypes.ALLOWED_JSON_CONTENT_TYPES, item.getContentType());
}
content = item.getAsString();
} else {
formItems.put(item.getFieldName(), item);
}
}
} else {
content = IOUtils.toString(request.getInputStream(), request.getCharacterEncoding());
}
return content;
}
protected void dispatchBatch(JSONArray batch, Map<String, FormDataItem> formItems ,
HttpServletRequest servletRequest, HttpServletResponse servletResponse,
SecurityToken token, String callback) throws JSONException, IOException {
// Use linked hash map to preserve order
List<Future<?>> responses = Lists.newArrayListWithCapacity(batch.length());
// Gather all Futures. We do this up front so that
// the first call to get() comes after all futures are created,
// which allows for implementations that batch multiple Futures
// into single requests.
for (int i = 0; i < batch.length(); i++) {
JSONObject batchObj = batch.getJSONObject(i);
responses.add(getHandler(batchObj, servletRequest).execute(formItems, token, jsonConverter));
}
// Resolve each Future into a response.
// TODO: should use shared deadline across each request
List<Object> result = new ArrayList<Object>(batch.length());
for (int i = 0; i < batch.length(); i++) {
JSONObject batchObj = batch.getJSONObject(i);
String key = null;
if (batchObj.has("id")) {
key = batchObj.getString("id");
}
result.add(getJSONResponse(key, getResponseItem(responses.get(i))));
}
// Generate the output
Writer writer = servletResponse.getWriter();
if (callback != null) writer.append(callback).append('(');
jsonConverter.append(writer, result);
if (callback != null) writer.append(");\n");
}
protected void dispatch(JSONObject request, Map<String, FormDataItem> formItems,
HttpServletRequest servletRequest, HttpServletResponse servletResponse,
SecurityToken token, String callback) throws JSONException, IOException {
String key = null;
if (request.has("id")) {
key = request.getString("id");
}
// getRpcHandler never returns null
Future<?> future = getHandler(request, servletRequest).execute(formItems, token, jsonConverter);
// Resolve each Future into a response.
// TODO: should use shared deadline across each request
ResponseItem response = getResponseItem(future);
Object result = getJSONResponse(key, response);
// Generate the output
Writer writer = servletResponse.getWriter();
if (callback != null) writer.append(callback).append('(');
jsonConverter.append(writer, result);
if (callback != null) writer.append(");\n");
}
/**
*
*/
protected void addResult(Map<String,Object> result, Object data) {
if (jsonRpcBothFields) {
result.put("result", data);
result.put("data", data);
}
result.put(jsonRpcResultField, data);
}
/**
* Determine if the content contains a batch request
*
* @param content json content or null
* @return true if content contains is a json array, not a json object or null
*/
private boolean isContentJsonBatch(String content) {
if (content == null) return false;
return ((content.indexOf('[') != -1) && content.indexOf('[') < content.indexOf('{'));
}
/**
* Wrap call to dispatcher to allow for implementation specific overrides
* and servlet-request contextual handling
*/
protected RpcHandler getHandler(JSONObject rpc, HttpServletRequest request) {
return dispatcher.getRpcHandler(rpc);
}
Object getJSONResponse(String key, ResponseItem responseItem) {
Map<String, Object> result = Maps.newHashMap();
if (key != null) {
result.put("id", key);
}
if (responseItem.getErrorCode() < 200 ||
responseItem.getErrorCode() >= 400) {
result.put("error", getErrorJson(responseItem));
} else {
Object response = responseItem.getResponse();
if (response instanceof DataCollection) {
addResult(result, ((DataCollection) response).getEntry());
} else if (response instanceof RestfulCollection) {
Map<String, Object> map = Maps.newHashMap();
RestfulCollection<?> collection = (RestfulCollection<?>) response;
// Return sublist info
if (collection.getTotalResults() != collection.getEntry().size()) {
map.put("startIndex", collection.getStartIndex());
map.put("itemsPerPage", collection.getItemsPerPage());
}
// always put in totalResults
map.put("totalResults", collection.getTotalResults());
if (!collection.isFiltered())
map.put("filtered", collection.isFiltered());
if (!collection.isUpdatedSince())
map.put("updatedSince", collection.isUpdatedSince());
if (!collection.isSorted())
map.put("sorted", collection.isUpdatedSince());
map.put("list", collection.getEntry());
addResult(result, map);
} else {
addResult(result, response);
}
// TODO: put "code" for != 200?
}
return result;
}
/** Map of old-style error titles */
private static final Map<Integer, String> errorTitles = ImmutableMap.<Integer, String> builder()
.put(HttpServletResponse.SC_NOT_IMPLEMENTED, "notImplemented")
.put(HttpServletResponse.SC_UNAUTHORIZED, "unauthorized")
.put(HttpServletResponse.SC_FORBIDDEN, "forbidden")
.put(HttpServletResponse.SC_BAD_REQUEST, "badRequest")
.put(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "internalError")
.put(HttpServletResponse.SC_EXPECTATION_FAILED, "limitExceeded")
.build();
// TODO(doll): Refactor the responseItem so that the fields on it line up with this format.
// Then we can use the general converter to output the response to the client and we won't
// be harcoded to json.
private Object getErrorJson(ResponseItem responseItem) {
Map<String, Object> error = new HashMap<String, Object>(2, 1);
error.put("code", responseItem.getErrorCode());
String message = errorTitles.get(responseItem.getErrorCode());
if (message == null) {
message = responseItem.getErrorMessage();
} else {
if (StringUtils.isNotBlank(responseItem.getErrorMessage())) {
message += ": " + responseItem.getErrorMessage();
}
}
if (StringUtils.isNotBlank(message)) {
error.put("message", message);
}
if (responseItem.getResponse() != null) {
error.put("data", responseItem.getResponse());
}
return error;
}
@Override
protected void sendError(HttpServletResponse servletResponse, ResponseItem responseItem)
throws IOException {
jsonConverter.append(servletResponse.getWriter(), getErrorJson(responseItem));
servletResponse.setStatus(responseItem.getErrorCode());
}
private void sendBadRequest(Throwable t, HttpServletResponse response) throws IOException {
sendError(response, new ResponseItem(HttpServletResponse.SC_BAD_REQUEST,
"Invalid input - " + t.getMessage()));
}
private void sendJsonParseError(JSONException e, HttpServletResponse response) throws IOException {
sendError(response, new ResponseItem(HttpServletResponse.SC_BAD_REQUEST,
"Invalid JSON - " + e.getMessage()));
}
}