/*
* #%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.filters;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Multimap;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.cookie.Cookie;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.entity.ContentType;
import org.apache.http.impl.client.BasicCookieStore;
import org.apache.http.impl.client.DefaultRedirectStrategy;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.message.BasicHttpEntityEnclosingRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.wisdom.api.bodies.RenderableStream;
import org.wisdom.api.configuration.Configuration;
import org.wisdom.api.http.*;
import org.wisdom.api.interception.Filter;
import org.wisdom.api.interception.RequestContext;
import org.wisdom.api.router.Route;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.regex.Pattern;
/**
* A filter implementation to extend to create a transparent proxy to a specific location.
*/
public class ProxyFilter implements Filter {
private static final Set<String> HOP_HEADERS = new HashSet<>();
static {
HOP_HEADERS.add("connection");
HOP_HEADERS.add("keep-alive");
HOP_HEADERS.add("proxy-authorization");
HOP_HEADERS.add("proxy-authenticate");
HOP_HEADERS.add("proxy-connection");
HOP_HEADERS.add("transfer-encoding");
HOP_HEADERS.add("te");
HOP_HEADERS.add("trailer");
HOP_HEADERS.add("upgrade");
}
protected final Configuration configuration;
protected Logger logger;
private HttpClient client;
private String proxyTo;
protected String prefix;
/**
* Default constructor, not configuration.
*/
public ProxyFilter() {
this(null);
}
/**
* Constructor receiving a configuration.
*
* @param conf the configuration
*/
public ProxyFilter(Configuration conf) {
configuration = conf;
logger = createLogger();
client = newHttpClient();
proxyTo = getProxyTo();
prefix = getPrefix();
if (proxyTo == null) {
throw new IllegalStateException("The 'proxyTo' parameter is required");
}
if (prefix == null) {
prefix = "";
}
}
/**
* Retrieves the HTTP Client instance used by this filter.
*
* @return the HTTP Client instance
*/
public HttpClient getClient() {
return client;
}
/**
* Allows you do override the HTTP Client used to execute the requests.
* By default, it used a custom client without cookies.
*
* @return the HTTP Client instance
*/
protected HttpClient newHttpClient() {
return HttpClients.custom()
// Do not manage redirection.
.setRedirectStrategy(new DefaultRedirectStrategy() {
@Override
protected boolean isRedirectable(String method) {
return followRedirect(method);
}
})
.setDefaultCookieStore(new BasicCookieStore() {
@Override
public synchronized List<Cookie> getCookies() {
return Collections.emptyList();
}
})
.build();
}
/**
* Customizes the redirect policy of the default HTTP Client.
*
* @param method the HTTP method
* @return {@code true} if the redirect can be following for the given method, {@code false} otherwise
*/
protected boolean followRedirect(String method) {
return false;
}
/**
* Creates the logger instance used by this filter. It can be overridden to customize the logger instance.
*
* @return the logger
*/
protected Logger createLogger() {
return LoggerFactory.getLogger(ProxyFilter.class.getName() + "-" + uri().toString());
}
/**
* The interception method. Re-emit the request to the target folder and forward the response. This method
* returns an {@link org.wisdom.api.http.AsyncResult} as the proxy need to be run in another thread. It also
* invokes a couple of callbacks letting developers to customize the request and result.
*
* @param route the route
* @param context the filter context
* @return the result
* @throws Exception if anything bad happen
*/
@Override
public Result call(final Route route, final RequestContext context) throws Exception {
return new AsyncResult(new Callable<Result>() {
@Override
public Result call() throws Exception {
URI rewrittenURI = rewriteURI(context);
logger.debug("Proxy request - rewriting {} to {}", context.request().uri(), rewrittenURI);
if (rewrittenURI == null) {
return onRewriteFailed(context);
}
BasicHttpEntityEnclosingRequest request
= new BasicHttpEntityEnclosingRequest(context.request().method(), rewrittenURI.toString());
// Any header listed by the Connection header must be removed:
// http://tools.ietf.org/html/rfc7230#section-6.1.
Set<String> hopHeaders = new HashSet<>();
List<String> connectionHeaders = context.request().headers().get(HeaderNames.CONNECTION);
for (String s : connectionHeaders) {
for (String entry : Splitter.on(",").omitEmptyStrings().trimResults().splitToList(s)) {
hopHeaders.add(entry.toLowerCase(Locale.ENGLISH));
}
}
boolean hasContent = context.request().contentType() != null;
final String host = getHost();
Multimap<String, String> headers = ArrayListMultimap.create();
for (Map.Entry<String, List<String>> entry : context.request().headers().entrySet()) {
String name = entry.getKey();
if (HeaderNames.TRANSFER_ENCODING.equalsIgnoreCase(name)) {
hasContent = true;
}
if (host != null && HeaderNames.HOST.equalsIgnoreCase(name)) {
continue;
}
// Remove hop-by-hop headers.
String lower = name.toLowerCase(Locale.ENGLISH);
if (HOP_HEADERS.contains(lower) || hopHeaders.contains(lower)) {
continue;
}
for (String v : entry.getValue()) {
headers.put(name, v);
}
}
// Force the Host header if configured
headers.removeAll(HeaderNames.HOST);
if (host != null) {
headers.put(HeaderNames.HOST, host);
headers.put("X-Forwarded-Server", host);
} else {
// Set of the URI one
headers.put("X-Forwarded-Server", rewrittenURI.getHost());
}
// Add proxy headers
if (getVia() != null) {
headers.put(HeaderNames.VIA, "http/1.1 " + getVia());
}
headers.put("X-Forwarded-For", context.request().remoteAddress());
if (host != null) {
headers.put("X-Forwarded-Host", host);
}
updateHeaders(context, headers);
for (Map.Entry<String, String> s : headers.entries()) {
request.addHeader(s.getKey(), s.getValue());
}
// Remove content-length as it is computed by the HTTP client.
request.removeHeaders(HeaderNames.CONTENT_LENGTH);
if (hasContent) {
ByteArrayEntity entity = new ByteArrayEntity(context.context().raw(),
ContentType.create(context.request().contentMimeType(), context.request().contentCharset()));
request.setEntity(entity);
}
HttpResponse response = client.execute(new HttpHost(rewrittenURI.getHost(), rewrittenURI.getPort()), request);
return onResult(toResult(response));
}
});
}
/**
* Callback that can be overridden to customize the header ot the request.
*
* @param context the request context
* @param headers the current set of headers, that need to be modified
*/
protected void updateHeaders(RequestContext context, Multimap<String, String> headers) {
// Do nothing by default.
}
private Result toResult(HttpResponse response) throws IOException {
Result result = new Result(response.getStatusLine().getStatusCode());
// Copy headers
for (Header h : response.getAllHeaders()) {
result.with(h.getName(), h.getValue());
}
// Copy content
HttpEntity entity = response.getEntity();
if (entity != null) {
result.render(new RenderableStream(entity.getContent()));
}
return result;
}
/**
* Callback invokes when the URL rewrite fails. By default, it returns an internal error.
*
* @param context the request context
* @return the result in case of rewrite failure, an internal error by default.
*/
protected Result onRewriteFailed(RequestContext context) {
return Results.internalServerError("Cannot proxy request - failed to compute destination");
}
/**
* The callback letting you override the result received from the target server.
*
* @param result the initial result
* @return the updated result
*/
protected Result onResult(Result result) {
return result;
}
/**
* Computes the URI where the request need to be transferred.
*
* @param rc the request content
* @return the URI
* @throws URISyntaxException if the URI cannot be computed
*/
public URI rewriteURI(RequestContext rc) throws URISyntaxException {
Request request = rc.request();
String path = request.path();
if (!path.startsWith(prefix)) {
return null;
}
return computeDestinationURI(request, path, proxyTo, prefix);
}
protected static URI computeDestinationURI(
Request request, String path, String proxyTo, String prefix) {
StringBuilder uri = new StringBuilder(proxyTo);
if (proxyTo.endsWith("/")) {
uri.setLength(uri.length() - 1);
}
String rest = path.substring(prefix.length());
if (!rest.startsWith("/") && !rest.isEmpty()) {
uri.append("/");
}
uri.append(rest);
// Do we have a query String
int index = request.uri().indexOf("?");
String query = null;
if (index != -1) {
// Remove the ?
query = request.uri().substring(index + 1);
}
if (!Strings.isNullOrEmpty(query)) {
uri.append("?").append(query);
}
return URI.create(uri.toString()).normalize();
}
/**
* Gets the Regex Pattern used to determine whether the route is handled by the filter or not.
* Notice that the router are caching these patterns and so cannot be changed.
*/
@Override
public Pattern uri() {
return Pattern.compile(getPrefix() + ".*");
}
/**
* Gets the filter priority, determining the position of the filter in the filter chain. Filter with a high
* priority are called first. Notice that the router are caching these priorities and so cannot changed.
* <p>
* It is heavily recommended to allow configuring the priority from the Application Configuration.
*
* @return the priority
*/
@Override
public int priority() {
return 1000;
}
/**
* Gets the host header to be sent. By default, it returns the 'host' entry of the configuration object. It can
* be overridden to return any value.
*
* @return the value of the host header
*/
protected String getHost() {
if (configuration == null) {
return null;
} else {
return configuration.get("host");
}
}
/**
* Gets the destination of the proxy. By default, it returns the 'proxyTo' entry of the configuration object. It
* can be overridden to return any value.
*
* @return the URL of the destination
*/
protected String getProxyTo() {
if (configuration == null) {
return null;
} else {
return configuration.get("proxyTo");
}
}
/**
* Gets the prefix of the proxy. By default, it returns the 'prefix' entry of the configuration object. It
* can be overridden to return any value.
*
* @return the URL of the destination
*/
protected String getPrefix() {
if (configuration == null) {
return "";
} else {
return configuration.get("prefix");
}
}
/**
* Gets the via header to be sent. By default, it returns the 'via' entry of the configuration object. It can
* be overridden to return any value.
*
* @return the value of the via header
*/
protected String getVia() {
if (configuration == null) {
return null;
} else {
return configuration.get("via");
}
}
}