org.gwtproject.rpc.servlet.RemoteServiceServlet Maven / Gradle / Ivy
/*
* #%L
* gwt-websockets-jsr356
* %%
* Copyright (C) 2011 - 2018 Vertispan LLC
* %%
* 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.
* #L%
*/
package org.gwtproject.rpc.servlet;
import org.gwtproject.rpc.api.Endpoint.NoRemoteEndpoint;
import org.gwtproject.rpc.api.RemoteService.RemoteServiceAsync;
import org.gwtproject.rpc.api.impl.AbstractEndpointImpl.EndpointImplConstructor;
import com.google.gwt.user.client.rpc.SerializationException;
import org.gwtproject.rpc.serialization.stream.string.StringSerializationStreamReader;
import org.gwtproject.rpc.serialization.stream.string.StringSerializationStreamWriter;
import org.gwtproject.rpc.api.impl.AbstractNoRemoteImpl;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.Locale;
import java.util.zip.GZIPOutputStream;
/**
* Handles POST calls with a similar API to GWT2-style RPC. The primary difference is
* that since no reflection is used to handle serialization or dispatch, both the policy
* wiring is absent, and this class must be created differently.
*/
public abstract class RemoteServiceServlet extends HttpServlet {
public static final String STRONG_NAME_HEADER = "X-GWT-RPC-Checksum";
//TODO move above to the shared interface
private static final String GWT_RPC_CONTENT_TYPE = "text/x-gwt-rpc";
private static final String CHARSET_UTF8_NAME = "UTF-8";
/**
* The UTF-8 Charset. Use this to avoid concurrency bottlenecks when
* converting between byte arrays and Strings.
* See http://code.google.com/p/google-web-toolkit/issues/detail?id=6398
*/
public static final Charset CHARSET_UTF8 = Charset.forName(CHARSET_UTF8_NAME);
static final int BUFFER_SIZE = 4096;
/**
* Controls the compression threshold at and below which no compression will
* take place.
*/
private static final int UNCOMPRESSED_BYTE_SIZE_LIMIT = 256;
private static final String ACCEPT_ENCODING = "Accept-Encoding";
private static final String ATTACHMENT = "attachment";
private static final String CONTENT_ENCODING_GZIP = "gzip";
private static final String CONTENT_DISPOSITION = "Content-Disposition";
private static final String CONTENT_ENCODING = "Content-Encoding";
private static final String CONTENT_TYPE_APPLICATION_JSON_UTF8 = "application/json; charset=utf-8";
private static final String GENERIC_FAILURE_MSG = "The call failed on the server; see server log for details";
protected transient ThreadLocal perThreadRequest;
protected transient ThreadLocal perThreadResponse;
/**
* The implementation of the service.
*/
private final S delegate;
private final EndpointImplConstructor> clientFactory;
/**
* The default constructor used by service implementations that
* extend this class. The servlet will delegate AJAX requests to
* the appropriate method in the subclass.
*/
public RemoteServiceServlet(EndpointImplConstructor> clientFactory) {
this.delegate = (S) this;
this.clientFactory = clientFactory;
}
/**
* The wrapping constructor used by service implementations that are
* separate from this class. The servlet will delegate AJAX
* requests to the appropriate method in the given object.
*/
public RemoteServiceServlet(S delegate, EndpointImplConstructor> clientFactory) {
this.delegate = delegate;
this.clientFactory = clientFactory;
}
@Override
protected final void doPost(HttpServletRequest request, HttpServletResponse response) {
// Ensure the thread-local data fields have been initialized
try {
// Store the request & response objects in thread-local storage.
//
synchronized (this) {
validateThreadLocalData();
perThreadRequest.set(request);
perThreadResponse.set(response);
}
processPost(request, response);
} catch (Throwable e) {
// Give a subclass a chance to either handle the exception or rethrow it
//
doUnexpectedFailure(e);
} finally {
// null the thread-locals to avoid holding request/response
//
perThreadRequest.set(null);
perThreadResponse.set(null);
}
}
/**
* Override this method to control what should happen when an exception
* escapes the {@link #doPost} method. The default implementation will log the
* failure and send a generic failure response to the client.
*
* An "expected failure" is an exception thrown by a service method that is
* declared in the signature of the service method. These exceptions are
* serialized back to the client, and are not passed to this method. This
* method is called only for exceptions or errors that are not part of the
* service method's signature, or that result from SecurityExceptions,
* SerializationExceptions, or other failures within the RPC framework.
*
* Note that if the desired behavior is to both send the GENERIC_FAILURE_MSG
* response AND to rethrow the exception, then this method should first send
* the GENERIC_FAILURE_MSG response itself (using getThreadLocalResponse), and
* then rethrow the exception. Rethrowing the exception will cause it to
* escape into the servlet container.
*
* @param e the exception which was thrown
*/
protected void doUnexpectedFailure(Throwable e) {
try {
getThreadLocalResponse().reset();
} catch (IllegalStateException ex) {
/*
* If we can't reset the request, the only way to signal that something
* has gone wrong is to throw an exception from here. It should be the
* case that we call the user's implementation code before emitting data
* into the response, so the only time that gets tripped is if the object
* serialization code blows up.
*/
throw new RuntimeException("Unable to report failure", e);
}
ServletContext servletContext = getServletContext();
writeResponseForUnexpectedFailure(servletContext,
getThreadLocalResponse(), e);
}
/**
* Called when the servlet itself has a problem, rather than the invoked
* third-party method. It writes a simple 500 message back to the client.
*
* @param servletContext
* @param response
* @param failure
*/
public static void writeResponseForUnexpectedFailure(
ServletContext servletContext, HttpServletResponse response,
Throwable failure) {
servletContext.log("Exception while dispatching incoming RPC call", failure);
// Send GENERIC_FAILURE_MSG with 500 status.
//
try {
response.setContentType("text/plain");
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
try {
response.getOutputStream().write(GENERIC_FAILURE_MSG.getBytes(CHARSET_UTF8));
} catch (IllegalStateException e) {
// Handle the (unexpected) case where getWriter() was previously used
response.getWriter().write(GENERIC_FAILURE_MSG);
}
} catch (IOException ex) {
servletContext.log(
"respondWithUnexpectedFailure failed while sending the previous failure to the client",
ex);
}
}
/**
* Returns the strong name of the permutation, as reported by the client that
* issued the request, or null
if it could not be determined.
* This information is encoded in the
* {@value STRONG_NAME_HEADER}
* HTTP header.
*/
protected final String getPermutationStrongName() {
return getThreadLocalRequest().getHeader(STRONG_NAME_HEADER);
}
/**
* Gets the HttpServletRequest
object for the current call. It is
* stored thread-locally so that simultaneous invocations can have different
* request objects.
*/
protected final HttpServletRequest getThreadLocalRequest() {
synchronized (this) {
validateThreadLocalData();
return perThreadRequest.get();
}
}
/**
* Gets the HttpServletResponse
object for the current call. It
* is stored thread-locally so that simultaneous invocations can have
* different response objects.
*/
protected final HttpServletResponse getThreadLocalResponse() {
synchronized (this) {
validateThreadLocalData();
return perThreadResponse.get();
}
}
/**
* Override this method to examine the deserialized version of the request
* before the call to the servlet method is made. The default implementation
* does nothing and need not be called by subclasses.
*
* @param rpcRequest
*/
@Deprecated
protected void onAfterRequestDeserialized(Object rpcRequest) {
}
/**
* This method is called by {@link #processCall(String)} and will throw a
* SecurityException if {@link #getPermutationStrongName()} returns
* null
. This method can be overridden to be a no-op if there are
* clients that are not expected to provide the
* {@value STRONG_NAME_HEADER}
* header.
*
* In the GWT3 version, this header is used to check that the client and
* server are consistent. If you override this, be sure to return the client's
* checksum, or just return null to make this a no-op.
*
* @throws SecurityException if {@link #getPermutationStrongName()} returns
* null
*/
protected String checkPermutationStrongName() throws SecurityException {
if (getPermutationStrongName() == null) {
throw new SecurityException(
"Blocked request without GWT permutation header (XSRF attack?)");
}
return getPermutationStrongName();
}
/**
* Process a call originating from the given request. This method calls
* {@link RemoteServiceServlet#checkPermutationStrongName()} to prevent
* possible XSRF attacks and then decodes the payload
using
* the generated serializers provided from the instance's constructor
* arguments. Once deserialized, it calls directly to the server method.
*
* Subclasses may optionally override this method to handle the payload in any
* way they desire (by routing the request to a framework component, for
* instance). The {@link HttpServletRequest} and {@link HttpServletResponse}
* can be accessed via the {@link #getThreadLocalRequest()} and
* {@link #getThreadLocalResponse()} methods.
*
* This is public so that it can be unit tested easily without HTTP.
*
* @param payload the UTF-8 request payload
* @return a string which encodes either the invoked method's success callback or
* failure callback. Otherwise unhandled exceptions will just cause a 500
* to be passed to the client.
* @throws SerializationException if we cannot serialize the response
* @throws RuntimeException if the service method throws an unchecked
* exception (the exception will be the one thrown by the service)
*/
public String processCall(String payload) throws SerializationException {
// First, check for possible XSRF situation
String checksum = checkPermutationStrongName();
// Construct the client instance
String[] holder = new String[1];
Runnable[] executeCall = new Runnable[1];
NoRemoteEndpoint c = clientFactory.create(
ts -> {
StringSerializationStreamWriter writer = new StringSerializationStreamWriter(ts);
writer.prepareToWrite();
return writer;
},
writer -> {
holder[0] = writer.toString();
},
(serializationStreamReaderConsumer, typeSerializer) -> {
// this will be called during creation before it is time to set it up, so we stash it away for right afterward
executeCall[0] = () -> serializationStreamReaderConsumer.accept(new StringSerializationStreamReader(typeSerializer, payload));
}
);
c.setRemote(delegate);
if (checksum != null) {
// verify the client and server are speaking the same version
String expected = ((AbstractNoRemoteImpl>) c).getChecksum();
if (!checksum.equals(expected)) {
throw new IllegalStateException("Expected checksum with value " + expected);
}
}
// execute the stashed function, causing the payload to be parsed and executed
executeCall[0].run();
assert holder[0] != null : "Async responses not yet supported!";
return holder[0];
}
/**
* Standard HttpServlet method: handle the POST.
*
* This doPost method swallows ALL exceptions, logs them in the
* ServletContext, and returns a GENERIC_FAILURE_MSG response with status code
* 500.
*
* @throws ServletException
* @throws SerializationException
*/
public final void processPost(HttpServletRequest request,
HttpServletResponse response) throws IOException, ServletException,
SerializationException {
// Read the request fully.
//
String requestPayload = readContent(request);
// Let subclasses see the serialized request.
//
onBeforeRequestDeserialized(requestPayload);
// Invoke the core dispatching logic, which returns the serialized
// result.
//
String responsePayload = processCall(requestPayload);
// Let subclasses see the serialized response.
//
onAfterResponseSerialized(responsePayload);
// Write the response.
//
writeResponse(request, response, responsePayload);
}
/**
* Override this method in order to control the parsing of the incoming
* request. For example, you may want to bypass the check of the Content-Type
* and character encoding headers in the request, as some proxies re-write the
* request headers. Note that bypassing these checks may expose the servlet to
* some cross-site vulnerabilities. Your implementation should comply with the
* HTTP/1.1 specification, which includes handling both requests which include
* a Content-Length header and requests utilizing Transfer-Encoding:
* chuncked
.
*
* @param request the incoming request
* @return the content of the incoming request encoded as a string.
*/
protected String readContent(HttpServletRequest request)
throws ServletException, IOException {
if (GWT_RPC_CONTENT_TYPE != null) {
assert (GWT_RPC_CONTENT_TYPE != null);
String contentType = request.getContentType();
boolean contentTypeIsOkay = false;
if (contentType != null) {
contentType = contentType.toLowerCase(Locale.ROOT);
/*
* NOTE:We use startsWith because some servlet engines, i.e. Tomcat, do
* not remove the charset component but others do.
*/
if (contentType.startsWith(GWT_RPC_CONTENT_TYPE.toLowerCase(Locale.ROOT))) {
contentTypeIsOkay = true;
}
}
if (!contentTypeIsOkay) {
throw new ServletException("Content-Type was '"
+ (contentType == null ? "(null)" : contentType) + "'. Expected '"
+ GWT_RPC_CONTENT_TYPE + "'.");
}
}
if (CHARSET_UTF8_NAME != null) {
assert (CHARSET_UTF8_NAME != null);
boolean encodingOkay = false;
String characterEncoding = request.getCharacterEncoding();
if (characterEncoding != null) {
/*
* TODO: It would seem that we should be able to use equalsIgnoreCase here
* instead of indexOf. Need to be sure that servlet engines return a
* properly parsed character encoding string if we decide to make this
* change.
*/
if (characterEncoding.toLowerCase(Locale.ROOT).contains(CHARSET_UTF8_NAME.toLowerCase(Locale.ROOT))) {
encodingOkay = true;
}
}
if (!encodingOkay) {
throw new ServletException("Character Encoding is '"
+ (characterEncoding == null ? "(null)" : characterEncoding)
+ "'. Expected '" + CHARSET_UTF8_NAME + "'");
}
}
/*
* Need to support 'Transfer-Encoding: chunked', so do not rely on
* presence of a 'Content-Length' request header.
*/
InputStream in = request.getInputStream();
byte[] buffer = new byte[BUFFER_SIZE];
ByteArrayOutputStream out = new ByteArrayOutputStream(BUFFER_SIZE);
try {
while (true) {
int byteCount = in.read(buffer);
if (byteCount == -1) {
break;
}
out.write(buffer, 0, byteCount);
}
return new String(out.toByteArray(), CHARSET_UTF8);
} finally {
if (in != null) {
in.close();
}
}
}
/**
* Initializes the perThreadRequest and perThreadResponse fields if they are
* null. This will occur the first time they are accessed after an instance of
* this class is constructed or deserialized. This method should be called
* from within a 'synchronized(this) {}' block in order to ensure that only
* one thread creates the objects.
*/
private void validateThreadLocalData() {
if (perThreadRequest == null) {
perThreadRequest = new ThreadLocal();
}
if (perThreadResponse == null) {
perThreadResponse = new ThreadLocal();
}
}
private void writeResponse(HttpServletRequest request,
HttpServletResponse response, String responsePayload) throws IOException {
boolean gzipEncode = acceptsGzipEncoding(request)
&& exceedsUncompressedContentLengthLimit(responsePayload);
writeResponse(getServletContext(), response,
responsePayload, gzipEncode);
}
/**
* Returns true
if the {@link HttpServletRequest} accepts Gzip
* encoding. This is done by checking that the accept-encoding header
* specifies gzip as a supported encoding.
*
* @param request the request instance to test for gzip encoding acceptance
* @return true
if the {@link HttpServletRequest} accepts Gzip
* encoding
*/
private static boolean acceptsGzipEncoding(HttpServletRequest request) {
assert (request != null);
String acceptEncoding = request.getHeader(ACCEPT_ENCODING);
if (null == acceptEncoding) {
return false;
}
return (acceptEncoding.indexOf(CONTENT_ENCODING_GZIP) != -1);
}
/**
* Returns true
if the response content's estimated UTF-8 byte
* length exceeds 256 bytes.
*
* @param content the contents of the response
* @return true
if the response content's estimated UTF-8 byte
* length exceeds 256 bytes
*/
private static boolean exceedsUncompressedContentLengthLimit(String content) {
return (content.length() * 2) > UNCOMPRESSED_BYTE_SIZE_LIMIT;
}
/**
* Write the response content into the {@link HttpServletResponse}. If
* gzipResponse
is true
, the response content will
* be gzipped prior to being written into the response.
*
* @param servletContext servlet context for this response
* @param response response instance
* @param responseContent a string containing the response content
* @param gzipResponse if true
the response content will be gzip
* encoded before being written into the response
* @throws IOException if reading, writing, or closing the response's output
* stream fails
*/
private static void writeResponse(ServletContext servletContext,
HttpServletResponse response, String responseContent, boolean gzipResponse)
throws IOException {
byte[] responseBytes = responseContent.getBytes(CHARSET_UTF8);
if (gzipResponse) {
// Compress the reply and adjust headers.
//
ByteArrayOutputStream output = null;
GZIPOutputStream gzipOutputStream = null;
Throwable caught = null;
try {
output = new ByteArrayOutputStream(responseBytes.length);
gzipOutputStream = new GZIPOutputStream(output);
gzipOutputStream.write(responseBytes);
gzipOutputStream.finish();
gzipOutputStream.flush();
setGzipEncodingHeader(response);
responseBytes = output.toByteArray();
} catch (IOException e) {
caught = e;
} finally {
if (null != gzipOutputStream) {
gzipOutputStream.close();
}
if (null != output) {
output.close();
}
}
if (caught != null) {
servletContext.log("Unable to compress response", caught);
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
return;
}
}
// Send the reply.
//
response.setContentLength(responseBytes.length);
response.setContentType(CONTENT_TYPE_APPLICATION_JSON_UTF8);
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader(CONTENT_DISPOSITION, ATTACHMENT);
response.getOutputStream().write(responseBytes);
}
/**
* Sets the correct header to indicate that a response is gzipped.
*/
public static void setGzipEncodingHeader(HttpServletResponse response) {
response.setHeader(CONTENT_ENCODING, CONTENT_ENCODING_GZIP);
}
/**
* Override this method to examine the serialized response that will be
* returned to the client. The default implementation does nothing and need
* not be called by subclasses.
*
* @param serializedResponse
*/
protected void onAfterResponseSerialized(String serializedResponse) {
}
/**
* Override this method to examine the serialized version of the request
* payload before it is deserialized into objects. The default implementation
* does nothing and need not be called by subclasses.
*
* @param serializedRequest
*/
protected void onBeforeRequestDeserialized(String serializedRequest) {
}
}