Java tutorial
// Copyright 2008 Google Inc. // // 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 com.google.gwtjsonrpc.server; import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST; import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.InstanceCreator; import com.google.gson.JsonElement; import com.google.gson.JsonNull; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import com.google.gson.JsonSerializationContext; import com.google.gson.JsonSerializer; import com.google.gwt.user.client.rpc.AsyncCallback; import com.google.gwt.user.server.rpc.RPCServletUtils; import com.google.gwtjsonrpc.client.CookieAccess; import com.google.gwtjsonrpc.client.JsonUtil; import com.google.gwtjsonrpc.client.RemoteJsonService; import org.apache.commons.codec.binary.Base64; import java.io.IOException; import java.io.InputStream; import java.io.OutputStreamWriter; import java.io.StringWriter; import java.io.UnsupportedEncodingException; import java.io.Writer; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.Type; import java.nio.ByteBuffer; import java.nio.charset.CharacterCodingException; import java.nio.charset.Charset; import java.nio.charset.CharsetDecoder; import java.nio.charset.CodingErrorAction; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; import javax.servlet.ServletConfig; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * Basic HTTP servlet to forward JSON based RPC requests onto services. * <p> * Implementors of a JSON-RPC service should extend this servlet and implement * any interface(s) that extend from {@link RemoteJsonService}. Clients may * invoke methods declared in any implemented interface. * <p> * <b>JSON-RPC 1.1</b><br> * Calling conventions match the JSON-RPC 1.1 working draft from 7 August 2006 * (<a href="http://json-rpc.org/wd/JSON-RPC-1-1-WD-20060807.html">draft</a>). * Only positional parameters are supported. * <p> * <b>JSON-RPC 2.0</b><br> * Calling conventions match the JSON-RPC 2.0 specification. * <p> * When supported by the browser/client, the "gzip" encoding is used to compress * the resulting JSON, reducing transfer time for the response data. */ public abstract class JsonServlet<CallType extends ActiveCall> extends HttpServlet { /** Pattern that any safe JSON-in-script callback conforms to. */ public static final Pattern SAFE_CALLBACK = Pattern.compile("^([A-Za-z0-9_$.]|\\[|\\])+$"); private static final ThreadLocal<ActiveCall> perThreadCall; static { perThreadCall = new ThreadLocal<ActiveCall>(); CookieAccess.setImplementation(new ServletCookieAccess()); } /** Get the <code>ActiveCall</code> object for the current call. */ @SuppressWarnings("unchecked") public static <CallType extends ActiveCall> CallType getCurrentCall() { return (CallType) perThreadCall.get(); } /** Create a default GsonBuilder with some extra types defined. */ public static GsonBuilder defaultGsonBuilder() { final GsonBuilder gb = new GsonBuilder(); gb.registerTypeAdapter(java.util.Set.class, new InstanceCreator<java.util.Set<Object>>() { public Set<Object> createInstance(final Type arg0) { return new HashSet<Object>(); } }); gb.registerTypeAdapter(java.util.Map.class, new MapDeserializer()); gb.registerTypeAdapter(java.sql.Date.class, new SqlDateDeserializer()); gb.registerTypeAdapter(java.sql.Timestamp.class, new SqlTimestampDeserializer()); return gb; } static final Object[] NO_PARAMS = {}; private static final String ENC = "UTF-8"; private Map<String, MethodHandle> myMethods; private SignedToken xsrf; @Override public void init(final ServletConfig config) throws ServletException { super.init(config); final RemoteJsonService impl; try { impl = (RemoteJsonService) createServiceHandle(); } catch (Exception e) { throw new ServletException("Service handle not available", e); } myMethods = methods(impl); if (myMethods.isEmpty()) { throw new ServletException("No service methods declared"); } try { xsrf = createXsrfSignedToken(); } catch (XsrfException e) { throw new ServletException("Cannot initialize XSRF", e); } } /** * Get the object which provides the RemoteJsonService implementation. * * @return by default <code>this</code>, but any object which implements a * RemoteJsonService interface. * @throws Exception any error indicating the service is not configured. */ protected Object createServiceHandle() throws Exception { return this; } /** * Initialize the XSRF state for this service. * <p> * By default this method creates a unique XSRF key for this service. Service * implementors may wish to override this method to use a pooled instance that * relies upon a stable private key. * * @return new XSRF implementation. Null if the caller has overridden all * relevant XSRF methods and is implementing their own XSRF protection * algorithm. * @throws XsrfException the XSRF utility could not be created. */ protected SignedToken createXsrfSignedToken() throws XsrfException { return new SignedToken(4 * 60 * 60 /* seconds */); } /** Create a GsonBuilder to parse a request or return a response. */ protected GsonBuilder createGsonBuilder() { return defaultGsonBuilder(); } /** * Verify the XSRF token submitted is valid. * <p> * By default this method validates the token, and refreshes it with a new * token for the currently authenticated user. * * @param call current RPC being processed. * @return true if the token was supplied and is valid; false otherwise. * @throws XsrfException the token could not be validated due to an error that * the client cannot recover from. */ protected boolean xsrfValidate(final CallType call) throws XsrfException { return call.xsrfValidate(); } /** * Lookup a method implemented by this servlet. * * @param methodName name of the method. * @return the method handle; null if the method is not declared. */ protected MethodHandle lookupMethod(final String methodName) { return myMethods.get(methodName); } /** * Create a new call structure for the active HTTP request. * * @param req the incoming request. * @param resp the response to return to the client. * @return the new call wrapping both. */ @SuppressWarnings("unchecked") protected CallType createActiveCall(final HttpServletRequest req, final HttpServletResponse resp) { return (CallType) new ActiveCall(req, resp); } /** @return maximum size of a JSON request, in bytes */ protected int maxRequestSize() { // Our default limit of 1 MB should be sufficient for nearly any // application. It takes a long time to format this on the client // or to upload it. // return 1 * 1024 * 1024; } /** * Invoked just before the service method is invoked. * <p> * Subclasses may override this method to perform additional checks, such as * per-method application level security validation. An override of this * method should take the following form: * * <pre> * protected void preInvoke(final CallType call) { * super.preInvoke(call); * if (call.isComplete()) { * return; * } * // your logic here * } * </pre> * <p> * If either the {@link ActiveCall#onFailure(Throwable)} or * {@link ActiveCall#onInternalFailure(Throwable)} method is invoked with a * non-null exception argument the method call itself will be bypassed and the * error response will be returned to the client instead. * * @param call the current call information. */ protected void preInvoke(final CallType call) { } @Override protected void service(final HttpServletRequest req, final HttpServletResponse resp) throws IOException { try { final CallType call = createActiveCall(req, resp); call.xsrf = xsrf; call.noCache(); if (!acceptJSON(call)) { textError(call, SC_BAD_REQUEST, "Must Accept " + JsonUtil.JSON_TYPE); return; } perThreadCall.set(call); doService(call); if (call.internalFailure != null) { // Hide internal errors from the client. // final String msg = "Error in " + call.method.getName(); getServletContext().log(msg, call.internalFailure); call.onFailure(new Exception("Internal Server Error")); } final String out = formatResult(call); RPCServletUtils.writeResponse(getServletContext(), call.httpResponse, out, call.callback == null && RPCServletUtils.acceptsGzipEncoding(call.httpRequest) && RPCServletUtils.exceedsUncompressedContentLengthLimit(out)); } finally { perThreadCall.set(null); } } private boolean acceptJSON(final CallType call) { final String accepts = call.httpRequest.getHeader("Accept"); if (accepts == null) { // A really odd client, it didn't send us an accept header? // return false; } if (JsonUtil.JSON_TYPE.equals(accepts)) { // Common case, as our JSON client side code sets only this // return true; } // The browser may take JSON, but also other types. The popular // Opera browser will add other accept types to our AJAX requests // even though our AJAX handler wouldn't be able to actually use // the data. The common case for these is to start with our own // type, then others, so we special case it before we go through // the expense of splitting the Accepts header up. // if (accepts.startsWith(JsonUtil.JSON_TYPE + ",")) { return true; } final String[] parts = accepts.split("[ ,;][ ,;]*"); for (final String p : parts) { if (JsonUtil.JSON_TYPE.equals(p)) { return true; } } // Assume the client is busted and won't take JSON back. // return false; } private void doService(final CallType call) throws IOException { try { try { if ("GET".equals(call.httpRequest.getMethod())) { parseGetRequest(call); if (!call.method.allowCrossSiteRequest()) { // Flat out refuse to service a method that requires XSRF // protection when requested over a GET method. Even if we // somehow managed to populate the XSRF token this is a very // insecure request against what must be a secure service method. // call.onFailure(new Exception(JsonUtil.ERROR_INVALID_XSRF)); return; } } else if ("POST".equals(call.httpRequest.getMethod())) { parsePostRequest(call); } else { call.httpResponse.setStatus(SC_BAD_REQUEST); call.onFailure(new Exception("Unsupported HTTP method")); return; } } catch (JsonParseException err) { if (err.getCause() instanceof NoSuchRemoteMethodException) { // GSON seems to catch our own exception and wrap it... // throw (NoSuchRemoteMethodException) err.getCause(); } call.httpResponse.setStatus(SC_BAD_REQUEST); call.onFailure(new Exception("Error parsing request", err)); return; } } catch (NoSuchRemoteMethodException err) { call.httpResponse.setStatus(SC_NOT_FOUND); call.onFailure(new Exception("No such service method")); return; } if (call.callback != null && !SAFE_CALLBACK.matcher(call.callback).matches()) { call.httpResponse.setStatus(SC_BAD_REQUEST); call.onFailure(new Exception("Unsafe name in 'callback' property")); return; } try { call.xsrfValid = xsrfValidate(call); } catch (XsrfException e) { getServletContext().log("Unexpected XSRF validation error", e); call.xsrfValid = false; } if (!call.method.allowCrossSiteRequest() && !call.requireXsrfValid()) { return; } preInvoke(call); if (!call.isComplete()) { call.method.invoke(call.params, call); } } private void parseGetRequest(final CallType call) { final HttpServletRequest req = call.httpRequest; if ("2.0".equals(req.getParameter("jsonrpc"))) { final JsonObject d = new JsonObject(); d.addProperty("jsonrpc", "2.0"); d.addProperty("method", req.getParameter("method")); d.addProperty("id", req.getParameter("id")); try { final byte[] params = req.getParameter("params").getBytes("ISO-8859-1"); final String p = new String(Base64.decodeBase64(params), "UTF-8"); d.add("params", new JsonParser().parse(p)); } catch (UnsupportedEncodingException e) { throw new JsonParseException("Cannot parse params", e); } try { final GsonBuilder gb = createGsonBuilder(); gb.registerTypeAdapter(ActiveCall.class, // new CallDeserializer<CallType>(call, this)); gb.create().fromJson(d, ActiveCall.class); } catch (JsonParseException err) { call.method = null; call.params = null; throw err; } } else { /* JSON-RPC 1.1 */ final Gson gs = createGsonBuilder().create(); call.method = lookupMethod(req.getParameter("method")); if (call.method == null) { throw new NoSuchRemoteMethodException(); } final Type[] paramTypes = call.method.getParamTypes(); final Object[] r = new Object[paramTypes.length]; for (int i = 0; i < r.length; i++) { final String v = req.getParameter("param" + i); if (v == null) { r[i] = null; } else if (paramTypes[i] == String.class) { r[i] = v; } else if (paramTypes[i] instanceof Class<?> && ((Class<?>) paramTypes[i]).isPrimitive()) { // Primitive type, use the JSON representation of that type. // r[i] = gs.fromJson(v, paramTypes[i]); } else { // Assume it is like a java.sql.Timestamp or something and treat // the value as JSON string. // r[i] = gs.fromJson(gs.toJson(v), paramTypes[i]); } } call.params = r; call.callback = req.getParameter("callback"); } } private static boolean isBodyJson(final ActiveCall call) { String type = call.httpRequest.getContentType(); if (type == null) { return false; } int semi = type.indexOf(';'); if (semi >= 0) { type = type.substring(0, semi).trim(); } return JsonUtil.JSON_TYPE.equals(type); } private static boolean isBodyUTF8(final ActiveCall call) { String enc = call.httpRequest.getCharacterEncoding(); if (enc == null) { enc = ""; } return enc.toLowerCase().contains(JsonUtil.JSON_ENC.toLowerCase()); } private String readBody(final ActiveCall call) throws IOException { if (!isBodyJson(call)) { throw new JsonParseException("Invalid Request Content-Type"); } if (!isBodyUTF8(call)) { throw new JsonParseException("Invalid Request Character-Encoding"); } final int len = call.httpRequest.getContentLength(); if (len < 0) { throw new JsonParseException("Invalid Request Content-Length"); } if (len == 0) { throw new JsonParseException("Invalid Request POST Body Required"); } if (len > maxRequestSize()) { throw new JsonParseException("Invalid Request POST Body Too Large"); } final InputStream in = call.httpRequest.getInputStream(); if (in == null) { throw new JsonParseException("Invalid Request POST Body Required"); } try { final byte[] body = new byte[len]; int off = 0; while (off < len) { final int n = in.read(body, off, len - off); if (n <= 0) { throw new JsonParseException("Invalid Request Incomplete Body"); } off += n; } final CharsetDecoder d = Charset.forName(JsonUtil.JSON_ENC).newDecoder(); d.onMalformedInput(CodingErrorAction.REPORT); d.onUnmappableCharacter(CodingErrorAction.REPORT); try { return d.decode(ByteBuffer.wrap(body)).toString(); } catch (CharacterCodingException e) { throw new JsonParseException("Invalid Request Not UTF-8", e); } } finally { in.close(); } } private void parsePostRequest(final CallType call) throws UnsupportedEncodingException, IOException { try { final GsonBuilder gb = createGsonBuilder(); gb.registerTypeAdapter(ActiveCall.class, new CallDeserializer<CallType>(call, this)); gb.create().fromJson(readBody(call), ActiveCall.class); } catch (JsonParseException err) { call.method = null; call.params = null; throw err; } } private String formatResult(final ActiveCall call) throws UnsupportedEncodingException, IOException { final GsonBuilder gb = createGsonBuilder(); gb.registerTypeAdapter(call.getClass(), new JsonSerializer<ActiveCall>() { public JsonElement serialize(final ActiveCall src, final Type typeOfSrc, final JsonSerializationContext context) { if (call.callback != null) { if (src.externalFailure != null) { return new JsonNull(); } return context.serialize(src.result); } final JsonObject r = new JsonObject(); r.add(src.versionName, src.versionValue); if (src.id != null) { r.add("id", src.id); } if (src.xsrfKeyOut != null) { r.addProperty("xsrfKey", src.xsrfKeyOut); } if (src.externalFailure != null) { final JsonObject error = new JsonObject(); if ("jsonrpc".equals(src.versionName)) { final int code = to2_0ErrorCode(src); error.addProperty("code", code); error.addProperty("message", src.externalFailure.getMessage()); } else { error.addProperty("name", "JSONRPCError"); error.addProperty("code", 999); error.addProperty("message", src.externalFailure.getMessage()); } r.add("error", error); } else { r.add("result", context.serialize(src.result)); } return r; } }); final StringWriter o = new StringWriter(); if (call.callback != null) { o.write(call.callback); o.write("("); } gb.create().toJson(call, o); if (call.callback != null) { o.write(");"); } o.close(); return o.toString(); } private int to2_0ErrorCode(final ActiveCall src) { final Throwable e = src.externalFailure; final Throwable i = src.internalFailure; if (e instanceof NoSuchRemoteMethodException || i instanceof NoSuchRemoteMethodException) { return -32601 /* Method not found. */; } if (e instanceof JsonParseException || i instanceof JsonParseException) { return -32700 /* Parse error. */; } return -32603 /* Internal error. */; } private static void textError(final ActiveCall call, final int status, final String message) throws IOException { final HttpServletResponse r = call.httpResponse; r.setStatus(status); r.setContentType("text/plain; charset=" + ENC); final Writer w = new OutputStreamWriter(r.getOutputStream(), ENC); try { w.write(message); } finally { w.close(); } } private static Map<String, MethodHandle> methods(final RemoteJsonService impl) { final Class<? extends RemoteJsonService> d = findInterface(impl.getClass()); if (d == null) { return Collections.<String, MethodHandle>emptyMap(); } final Map<String, MethodHandle> r = new HashMap<String, MethodHandle>(); for (final Method m : d.getMethods()) { if (!Modifier.isPublic(m.getModifiers())) { continue; } if (m.getReturnType() != Void.TYPE) { continue; } final Class<?>[] params = m.getParameterTypes(); if (params.length < 1) { continue; } if (!params[params.length - 1].isAssignableFrom(AsyncCallback.class)) { continue; } final MethodHandle h = new MethodHandle(impl, m); r.put(h.getName(), h); } return Collections.unmodifiableMap(r); } @SuppressWarnings("unchecked") private static Class<? extends RemoteJsonService> findInterface(Class<?> c) { while (c != null) { if (c.isInterface() && RemoteJsonService.class.isAssignableFrom(c)) { return (Class<RemoteJsonService>) c; } for (final Class<?> i : c.getInterfaces()) { final Class<? extends RemoteJsonService> r = findInterface(i); if (r != null) { return r; } } c = c.getSuperclass(); } return null; } }