/*
* #%L
* Wisdom-Framework
* %%
* Copyright (C) 2013 - 2014 Wisdom Framework
* %%
* 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.wisdom.framework.vertx;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import io.netty.handler.codec.http.cookie.ServerCookieEncoder;
import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.http.HttpServerResponse;
import io.vertx.core.streams.Pump;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.wisdom.api.bodies.NoHttpBody;
import org.wisdom.api.concurrent.ManagedFutureTask;
import org.wisdom.api.exceptions.ExceptionMapper;
import org.wisdom.api.exceptions.HttpException;
import org.wisdom.api.http.*;
import org.wisdom.api.router.Route;
import org.wisdom.framework.vertx.cookies.CookieHelper;
import org.wisdom.framework.vertx.file.DiskFileUpload;
import org.wisdom.framework.vertx.file.MixedFileUpload;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Handles HTTP Request. Don't forget that request may arrive as chunk.
*/
public class HttpHandler implements Handler<HttpServerRequest> {
/**
* The server name.
*/
private static final String SERVER_NAME = "Wisdom-Framework/" + BuildConstants.WISDOM_VERSION + " Vert.x/" +
BuildConstants.VERTX_VERSION;
private static final Logger LOGGER = LoggerFactory.getLogger(HttpHandler.class);
private final ServiceAccessor accessor;
private final Vertx vertx;
private final Server server;
/**
* Creates the handler.
*
* @param vertx the vertx singleton
* @param accessor the accessor
* @param server the server configuration - used to check whether or not the message should be
* allowed or denied
*/
public HttpHandler(Vertx vertx, ServiceAccessor accessor, Server server) {
this.accessor = accessor;
this.vertx = vertx;
this.server = server;
}
/**
* Handles a new HTTP request.
* The actual reading of the request is delegated to the {@link org.wisdom.framework.vertx.ContextFromVertx} and
* {@link org.wisdom.framework.vertx.RequestFromVertx} classes. However, the close handler is set here and
* trigger the request dispatch (i.e. Wisdom processing).
*
* @param request the request
*/
@Override
public void handle(final HttpServerRequest request) {
LOGGER.debug("A request has arrived on the server : {} {}", request.method(), request.path());
final ContextFromVertx context = new ContextFromVertx(vertx, vertx.getOrCreateContext(), accessor, request);
if (!server.accept(request.path())) {
LOGGER.warn("Request on {} denied by {}", request.path(), server.name());
writeResponse(context, (RequestFromVertx) context.request(),
server.getOnDeniedResult(),
false,
true);
} else {
Buffer raw = Buffer.buffer(0);
RequestFromVertx req = (RequestFromVertx) context.request();
AtomicBoolean error = new AtomicBoolean();
if (HttpUtils.isPostOrPut(request)) {
request.setExpectMultipart(true);
request.uploadHandler(upload -> req.getFiles().add(new MixedFileUpload(context.vertx(), upload,
accessor.getConfiguration().getLongWithDefault("http.upload.disk.threshold", DiskFileUpload.MINSIZE),
accessor.getConfiguration().getLongWithDefault("http.upload.max", -1L),
r -> {
request.uploadHandler(null);
request.handler(null);
error.set(true);
writeResponse(context, req, r, false, true);
})
));
}
int maxBodySize =
accessor.getConfiguration().getIntegerWithDefault("request.body.max.size", 100 * 1024);
request.handler(event -> {
if (event == null) {
return;
}
// To avoid we run out of memory we cut the read body to 100Kb. This can be configured using the
// "request.body.max.size" property.
boolean exceeded = raw.length() >= maxBodySize;
// We may have the content in different HTTP message, check if we already have a content.
// Issue #257.
if (!exceeded) {
raw.appendBuffer(event);
} else {
// Remove the handler as we stop reading the request.
request.handler(null);
error.set(true);
writeResponse(context, req, new Result(Status.PAYLOAD_TOO_LARGE)
.render("Body size exceeded - request cancelled")
.as(MimeTypes.TEXT),
false, true);
}
});
request.endHandler(event -> {
if (error.get()) {
// Error already written.
return;
}
req.setRawBody(raw);
// Notifies the context that the request has been read, we start the dispatching.
if (context.ready()) {
// Dispatch.
dispatch(context, (RequestFromVertx) context.request());
} else {
writeResponse(context, req,
Results.badRequest("Request processing failed"), false, true);
}
});
}
}
/**
* The request is now completed, clean everything.
*
* @param context the context
*/
private static void cleanup(ContextFromVertx context) {
// Release all resources, especially uploaded file.
if (context != null) {
context.cleanup();
}
Context.CONTEXT.remove();
}
private void dispatch(ContextFromVertx context, RequestFromVertx request) {
LOGGER.debug("Dispatching {} {}", context.request().method(), context.path());
// 2 Register context
Context.CONTEXT.set(context);
// 3 Get route for context
Route route = accessor.getRouter().getRouteFor(context.request().method(), context.path(), request);
Result result;
if (route == null) {
// 3.1 : no route to destination
// Should never return null, but an unbound route instead.
LOGGER.error("The router has returned 'null' instead of an unbound route for " + context.path());
result = Results.notFound();
} else {
// 3.2 : route found
context.route(route);
result = invoke(route);
if (result instanceof AsyncResult) {
// Asynchronous operation in progress.
handleAsyncResult(context, request, (AsyncResult) result);
return;
}
}
// Synchronous processing or not found.
try {
writeResponse(context, request, result, true, false);
} catch (Exception e) {
LOGGER.error("Cannot write response", e);
result = Results.internalServerError(e);
try {
writeResponse(context, request, result, false, false);
} catch (Exception e1) {
LOGGER.error("Cannot even write the error response...", e1);
// Ignore.
}
}
// If we reach this point, it means we did not write anything... Annoying.
}
private Result invoke(Route route) {
try {
return route.invoke();
} catch (Throwable e) { //NOSONAR
if (e.getCause() != null) {
// We don't really care about the parent exception, dump the cause only.
LOGGER.error("An error occurred during route invocation", e.getCause());
return Results.internalServerError(e.getCause());
} else {
LOGGER.error("An error occurred during route invocation", e);
return Results.internalServerError(e);
}
}
}
private void handleAsyncResult(
final ContextFromVertx context,
final RequestFromVertx request,
final AsyncResult asyncResult) {
ManagedFutureTask<Result> future = accessor.getExecutor().submit(asyncResult.callable());
Futures.addCallback(future, new FutureCallback<Result>() {
@Override
public void onSuccess(Result result) {
// We got a result, write it here.
// Merge the headers of the initial result and the async results.
final Map<String, String> headers = result.getHeaders();
for (Map.Entry<String, String> header : asyncResult.getHeaders().entrySet()) {
if (!headers.containsKey(header.getKey())) {
headers.put(header.getKey(), header.getValue());
}
}
writeResponse(context, request, result, true, false);
}
@Override
public void onFailure(Throwable t) {
//We got a failure, handle it here
// Check whether it's a HTTPException
if (t instanceof HttpException) {
writeResponse(context, request, ((HttpException) t).toResult(), false, false);
return;
}
// Check if we have a mapper
if (t instanceof Exception) {
ExceptionMapper mapper = accessor.getExceptionMapper((Exception) t);
if (mapper != null) {
writeResponse(context, request, mapper.toResult((Exception) t), false, false);
return;
}
}
writeResponse(context, request, Results.internalServerError(t), false, false);
}
}/*, MoreExecutors.directExecutor()*/);
//TODO Which executor should we use here ?
}
private void writeResponse(
ContextFromVertx context,
RequestFromVertx request,
Result result,
boolean handleFlashAndSessionCookie,
boolean closeConnection) {
//Retrieve the renderable object.
Renderable<?> renderable = result.getRenderable();
if (renderable == null) {
renderable = NoHttpBody.INSTANCE;
}
InputStream stream;
boolean success = true;
try {
// Process the result, and apply serialization if required.
stream = HttpUtils.processResult(accessor, context, renderable, result);
} catch (Exception e) {
LOGGER.error("Cannot render the response to " + request.uri(), e);
stream = new ByteArrayInputStream(NoHttpBody.empty());
success = false;
}
// If the content is too big or too small, disable encoding.
// First get the length of the content, it can be either the length of the renderable object. If not set, we
// have to check whether or not the length is given in the header.
long length = renderable.length();
if (length == 0 && result.getHeaders().get(HeaderNames.CONTENT_LENGTH) != null) {
length = Long.valueOf(result.getHeaders().get(HeaderNames.CONTENT_LENGTH));
}
// Check whether the length is not in range.
if (length != 0 && shouldEncodingBeDisabledForResponse(length, result)) {
LOGGER.debug("Disabling encoding for {} - size ({} bytes) not in range",
request.path(), length);
result.withoutCompression();
}
finalizeWriteReponse(context, request.getVertxRequest(),
result, stream, success, handleFlashAndSessionCookie, closeConnection);
}
/**
* This method must be called in a Vert.X context. It finalizes the response and send it to the client.
*
* @param context the HTTP context
* @param request the Vert.x request
* @param result the computed result
* @param stream the stream of the result
* @param success a flag indicating whether or not the request was successfully handled
* @param handleFlashAndSessionCookie if the flash and session cookie need to be send with the response
* @param closeConnection whehter or not the (underlying) TCP connection must be closed
*/
private void finalizeWriteReponse(
final ContextFromVertx context,
final HttpServerRequest request,
Result result,
InputStream stream,
boolean success,
boolean handleFlashAndSessionCookie,
boolean closeConnection) {
Renderable<?> renderable = result.getRenderable();
if (renderable == null) {
renderable = NoHttpBody.INSTANCE;
}
// Decide whether to close the connection or not.
boolean keepAlive = HttpUtils.isKeepAlive(request);
// Build the response object.
final HttpServerResponse response = request.response();
// Copy headers from the result
for (Map.Entry<String, String> header : result.getHeaders().entrySet()) {
response.putHeader(header.getKey(), header.getValue());
}
if (!result.getHeaders().containsKey(HeaderNames.SERVER)) {
// Add the server metadata
response.putHeader(HeaderNames.SERVER, SERVER_NAME);
}
String fullContentType = result.getFullContentType();
if (fullContentType == null) {
if (renderable.mimetype() != null) {
response.putHeader(HeaderNames.CONTENT_TYPE, renderable.mimetype());
}
} else {
response.putHeader(HeaderNames.CONTENT_TYPE, fullContentType);
}
// copy cookies / flash and session
if (handleFlashAndSessionCookie) {
context.flash().save(context, result);
context.session().save(context, result);
}
// copy cookies
for (org.wisdom.api.cookies.Cookie cookie : result.getCookies()) {
// Encode cookies:
final String encoded = ServerCookieEncoder.LAX.encode(
CookieHelper.convertWisdomCookieToNettyCookie(cookie));
// Here we use the 'add' method to add a new value to the header.
response.headers().add(HeaderNames.SET_COOKIE, encoded);
}
response.setStatusCode(HttpUtils.getStatusFromResult(result, success));
if (renderable.mustBeChunked()) {
LOGGER.debug("Building the chunked response for {} {} ({})", request.method(), request.uri(), context);
if (renderable.length() > 0 && !response.headers().contains(HeaderNames.CONTENT_LENGTH)) {
response.putHeader(HeaderNames.CONTENT_LENGTH, Long.toString(renderable.length()));
}
if (!response.headers().contains(HeaderNames.CONTENT_TYPE)) {
// No content is not legal, set default to binary.
response.putHeader(HeaderNames.CONTENT_TYPE, MimeTypes.BINARY);
}
// Can't determine the size, so switch to chunked.
response.setChunked(true);
response.putHeader(HeaderNames.TRANSFER_ENCODING, "chunked");
// In addition, we can't keep the connection open.
response.putHeader(HeaderNames.CONNECTION, "close");
final AsyncInputStream s = new AsyncInputStream(vertx, accessor.getExecutor(), stream);
s.setContext(context.vertxContext());
final Pump pump = Pump.pump(s, response);
s.endHandler(event -> context.vertxContext().runOnContext(event1 -> {
LOGGER.debug("Ending chunked response for {}", request.uri());
response.end();
response.close();
cleanup(context);
})
);
s.exceptionHandler(event -> context.vertxContext().runOnContext(event1 -> {
LOGGER.error("Cannot read the result stream", event1);
response.close();
cleanup(context);
})
);
context.vertxContext().runOnContext(event -> pump.start());
} else {
byte[] cont = new byte[0];
try {
cont = IOUtils.toByteArray(stream);
} catch (IOException e) {
LOGGER.error("Cannot copy the response to {}", request.uri(), e);
}
if (!response.headers().contains(HeaderNames.CONTENT_LENGTH)) {
// Because of the HEAD implementation, if the length is already set, do not update it.
// (HEAD would mean no content)
response.putHeader(HeaderNames.CONTENT_LENGTH, Long.toString(cont.length));
}
if (keepAlive) {
// Add keep alive header as per:
// - http://www.w3.org/Protocols/HTTP/1.1/draft-ietf-http-v11-spec-01.html#Connection
response.putHeader(HeaderNames.CONNECTION, "keep-alive");
}
response.write(Buffer.buffer(cont));
if (HttpUtils.isKeepAlive(request) && !closeConnection) {
response.end();
} else {
response.end();
response.close();
}
cleanup(context);
}
}
private boolean shouldEncodingBeDisabledForResponse(long length, Result result) {
return server.hasCompressionEnabled()
&& (
length < server.getEncodingMinBound() // Too small
|| length > server.getEncodingMaxBound() // Too big
);
}
}