Add support for a RestClient base path

This enables simple support for proxies (beyond proxy host and proxy port, which is done via the RequestConfig)) to provide a base path in front of all requests performed by the RestClient.
This commit is contained in:
Chris Earle 2016-08-27 01:12:55 -04:00
parent 6ad92c0f9d
commit 335c020cd7
6 changed files with 184 additions and 20 deletions

View File

@ -89,17 +89,19 @@ public class RestClient implements Closeable {
//we don't rely on default headers supported by HttpAsyncClient as those cannot be replaced //we don't rely on default headers supported by HttpAsyncClient as those cannot be replaced
private final Header[] defaultHeaders; private final Header[] defaultHeaders;
private final long maxRetryTimeoutMillis; private final long maxRetryTimeoutMillis;
private final String pathPrefix;
private final AtomicInteger lastHostIndex = new AtomicInteger(0); private final AtomicInteger lastHostIndex = new AtomicInteger(0);
private volatile Set<HttpHost> hosts; private volatile Set<HttpHost> hosts;
private final ConcurrentMap<HttpHost, DeadHostState> blacklist = new ConcurrentHashMap<>(); private final ConcurrentMap<HttpHost, DeadHostState> blacklist = new ConcurrentHashMap<>();
private final FailureListener failureListener; private final FailureListener failureListener;
RestClient(CloseableHttpAsyncClient client, long maxRetryTimeoutMillis, Header[] defaultHeaders, RestClient(CloseableHttpAsyncClient client, long maxRetryTimeoutMillis, Header[] defaultHeaders,
HttpHost[] hosts, FailureListener failureListener) { HttpHost[] hosts, String pathPrefix, FailureListener failureListener) {
this.client = client; this.client = client;
this.maxRetryTimeoutMillis = maxRetryTimeoutMillis; this.maxRetryTimeoutMillis = maxRetryTimeoutMillis;
this.defaultHeaders = defaultHeaders; this.defaultHeaders = defaultHeaders;
this.failureListener = failureListener; this.failureListener = failureListener;
this.pathPrefix = pathPrefix;
setHosts(hosts); setHosts(hosts);
} }
@ -280,7 +282,7 @@ public class RestClient implements Closeable {
public void performRequestAsync(String method, String endpoint, Map<String, String> params, public void performRequestAsync(String method, String endpoint, Map<String, String> params,
HttpEntity entity, HttpAsyncResponseConsumer<HttpResponse> responseConsumer, HttpEntity entity, HttpAsyncResponseConsumer<HttpResponse> responseConsumer,
ResponseListener responseListener, Header... headers) { ResponseListener responseListener, Header... headers) {
URI uri = buildUri(endpoint, params); URI uri = buildUri(pathPrefix, endpoint, params);
HttpRequestBase request = createHttpRequest(method, uri, entity); HttpRequestBase request = createHttpRequest(method, uri, entity);
setHeaders(request, headers); setHeaders(request, headers);
FailureTrackingResponseListener failureTrackingResponseListener = new FailureTrackingResponseListener(responseListener); FailureTrackingResponseListener failureTrackingResponseListener = new FailureTrackingResponseListener(responseListener);
@ -501,10 +503,21 @@ public class RestClient implements Closeable {
return httpRequest; return httpRequest;
} }
private static URI buildUri(String path, Map<String, String> params) { private static URI buildUri(String pathPrefix, String path, Map<String, String> params) {
Objects.requireNonNull(params, "params must not be null"); Objects.requireNonNull(params, "params must not be null");
try { try {
URIBuilder uriBuilder = new URIBuilder(path); String fullPath;
if (pathPrefix != null) {
if (path.startsWith("/")) {
fullPath = pathPrefix + path;
} else {
fullPath = pathPrefix + "/" + path;
}
} else {
fullPath = path;
}
URIBuilder uriBuilder = new URIBuilder(fullPath);
for (Map.Entry<String, String> param : params.entrySet()) { for (Map.Entry<String, String> param : params.entrySet()) {
uriBuilder.addParameter(param.getKey(), param.getValue()); uriBuilder.addParameter(param.getKey(), param.getValue());
} }

View File

@ -51,12 +51,17 @@ public final class RestClientBuilder {
private RestClient.FailureListener failureListener; private RestClient.FailureListener failureListener;
private HttpClientConfigCallback httpClientConfigCallback; private HttpClientConfigCallback httpClientConfigCallback;
private RequestConfigCallback requestConfigCallback; private RequestConfigCallback requestConfigCallback;
private String pathPrefix;
/** /**
* Creates a new builder instance and sets the hosts that the client will send requests to. * Creates a new builder instance and sets the hosts that the client will send requests to.
*
* @throws NullPointerException if {@code hosts} or any host is {@code null}.
* @throws IllegalArgumentException if {@code hosts} is empty.
*/ */
RestClientBuilder(HttpHost... hosts) { RestClientBuilder(HttpHost... hosts) {
if (hosts == null || hosts.length == 0) { Objects.requireNonNull(hosts, "hosts must not be null");
if (hosts.length == 0) {
throw new IllegalArgumentException("no hosts provided"); throw new IllegalArgumentException("no hosts provided");
} }
for (HttpHost host : hosts) { for (HttpHost host : hosts) {
@ -67,6 +72,8 @@ public final class RestClientBuilder {
/** /**
* Sets the default request headers, which will be sent along with each request * Sets the default request headers, which will be sent along with each request
*
* @throws NullPointerException if {@code defaultHeaders} or any header is {@code null}.
*/ */
public RestClientBuilder setDefaultHeaders(Header[] defaultHeaders) { public RestClientBuilder setDefaultHeaders(Header[] defaultHeaders) {
Objects.requireNonNull(defaultHeaders, "defaultHeaders must not be null"); Objects.requireNonNull(defaultHeaders, "defaultHeaders must not be null");
@ -79,6 +86,8 @@ public final class RestClientBuilder {
/** /**
* Sets the {@link RestClient.FailureListener} to be notified for each request failure * Sets the {@link RestClient.FailureListener} to be notified for each request failure
*
* @throws NullPointerException if {@code failureListener} is {@code null}.
*/ */
public RestClientBuilder setFailureListener(RestClient.FailureListener failureListener) { public RestClientBuilder setFailureListener(RestClient.FailureListener failureListener) {
Objects.requireNonNull(failureListener, "failureListener must not be null"); Objects.requireNonNull(failureListener, "failureListener must not be null");
@ -90,7 +99,7 @@ public final class RestClientBuilder {
* Sets the maximum timeout (in milliseconds) to honour in case of multiple retries of the same request. * Sets the maximum timeout (in milliseconds) to honour in case of multiple retries of the same request.
* {@link #DEFAULT_MAX_RETRY_TIMEOUT_MILLIS} if not specified. * {@link #DEFAULT_MAX_RETRY_TIMEOUT_MILLIS} if not specified.
* *
* @throws IllegalArgumentException if maxRetryTimeoutMillis is not greater than 0 * @throws IllegalArgumentException if {@code maxRetryTimeoutMillis} is not greater than 0
*/ */
public RestClientBuilder setMaxRetryTimeoutMillis(int maxRetryTimeoutMillis) { public RestClientBuilder setMaxRetryTimeoutMillis(int maxRetryTimeoutMillis) {
if (maxRetryTimeoutMillis <= 0) { if (maxRetryTimeoutMillis <= 0) {
@ -102,6 +111,8 @@ public final class RestClientBuilder {
/** /**
* Sets the {@link HttpClientConfigCallback} to be used to customize http client configuration * Sets the {@link HttpClientConfigCallback} to be used to customize http client configuration
*
* @throws NullPointerException if {@code httpClientConfigCallback} is {@code null}.
*/ */
public RestClientBuilder setHttpClientConfigCallback(HttpClientConfigCallback httpClientConfigCallback) { public RestClientBuilder setHttpClientConfigCallback(HttpClientConfigCallback httpClientConfigCallback) {
Objects.requireNonNull(httpClientConfigCallback, "httpClientConfigCallback must not be null"); Objects.requireNonNull(httpClientConfigCallback, "httpClientConfigCallback must not be null");
@ -111,6 +122,8 @@ public final class RestClientBuilder {
/** /**
* Sets the {@link RequestConfigCallback} to be used to customize http client configuration * Sets the {@link RequestConfigCallback} to be used to customize http client configuration
*
* @throws NullPointerException if {@code requestConfigCallback} is {@code null}.
*/ */
public RestClientBuilder setRequestConfigCallback(RequestConfigCallback requestConfigCallback) { public RestClientBuilder setRequestConfigCallback(RequestConfigCallback requestConfigCallback) {
Objects.requireNonNull(requestConfigCallback, "requestConfigCallback must not be null"); Objects.requireNonNull(requestConfigCallback, "requestConfigCallback must not be null");
@ -118,6 +131,43 @@ public final class RestClientBuilder {
return this; return this;
} }
/**
* Sets the path's prefix for every request used by the http client.
* <p>
* For example, if this is set to "/my/path", then any client request will become <code>"/my/path/" + endpoint</code>.
* <p>
* In essence, every request's {@code endpoint} is prefixed by this {@code pathPrefix}. The path prefix is useful for when
* Elasticsearch is behind a proxy that provides a base path; it is not intended for other purposes and it should not be supplied in
* other scenarios.
*
* @throws NullPointerException if {@code pathPrefix} is {@code null}.
* @throws IllegalArgumentException if {@code pathPrefix} is empty, only '/', or ends with more than one '/'.
*/
public RestClientBuilder setPathPrefix(String pathPrefix) {
Objects.requireNonNull(pathPrefix, "pathPrefix must not be null");
String cleanPathPrefix = pathPrefix;
if (cleanPathPrefix.startsWith("/") == false) {
cleanPathPrefix = "/" + cleanPathPrefix;
}
// best effort to ensure that it looks like "/base/path" rather than "/base/path/"
if (cleanPathPrefix.endsWith("/")) {
cleanPathPrefix = cleanPathPrefix.substring(0, cleanPathPrefix.length() - 1);
if (cleanPathPrefix.endsWith("/")) {
throw new IllegalArgumentException("pathPrefix is malformed. too many trailing slashes: [" + pathPrefix + "]");
}
}
if (cleanPathPrefix.isEmpty() || "/".equals(cleanPathPrefix)) {
throw new IllegalArgumentException("pathPrefix must not be empty or '/': [" + pathPrefix + "]");
}
this.pathPrefix = cleanPathPrefix;
return this;
}
/** /**
* Creates a new {@link RestClient} based on the provided configuration. * Creates a new {@link RestClient} based on the provided configuration.
*/ */
@ -126,7 +176,7 @@ public final class RestClientBuilder {
failureListener = new RestClient.FailureListener(); failureListener = new RestClient.FailureListener();
} }
CloseableHttpAsyncClient httpClient = createHttpClient(); CloseableHttpAsyncClient httpClient = createHttpClient();
RestClient restClient = new RestClient(httpClient, maxRetryTimeout, defaultHeaders, hosts, failureListener); RestClient restClient = new RestClient(httpClient, maxRetryTimeout, defaultHeaders, hosts, pathPrefix, failureListener);
httpClient.start(); httpClient.start();
return restClient; return restClient;
} }

View File

@ -19,7 +19,6 @@
package org.elasticsearch.client; package org.elasticsearch.client;
import com.carrotsearch.randomizedtesting.generators.RandomInts;
import org.apache.http.Header; import org.apache.http.Header;
import org.apache.http.HttpHost; import org.apache.http.HttpHost;
import org.apache.http.client.config.RequestConfig; import org.apache.http.client.config.RequestConfig;
@ -28,8 +27,10 @@ import org.apache.http.message.BasicHeader;
import java.io.IOException; import java.io.IOException;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.fail; import static org.junit.Assert.fail;
public class RestClientBuilderTests extends RestClientTestCase { public class RestClientBuilderTests extends RestClientTestCase {
@ -38,8 +39,8 @@ public class RestClientBuilderTests extends RestClientTestCase {
try { try {
RestClient.builder((HttpHost[])null); RestClient.builder((HttpHost[])null);
fail("should have failed"); fail("should have failed");
} catch(IllegalArgumentException e) { } catch(NullPointerException e) {
assertEquals("no hosts provided", e.getMessage()); assertEquals("hosts must not be null", e.getMessage());
} }
try { try {
@ -62,7 +63,7 @@ public class RestClientBuilderTests extends RestClientTestCase {
try { try {
RestClient.builder(new HttpHost("localhost", 9200)) RestClient.builder(new HttpHost("localhost", 9200))
.setMaxRetryTimeoutMillis(RandomInts.randomIntBetween(getRandom(), Integer.MIN_VALUE, 0)); .setMaxRetryTimeoutMillis(randomIntBetween(Integer.MIN_VALUE, 0));
fail("should have failed"); fail("should have failed");
} catch(IllegalArgumentException e) { } catch(IllegalArgumentException e) {
assertEquals("maxRetryTimeoutMillis must be greater than 0", e.getMessage()); assertEquals("maxRetryTimeoutMillis must be greater than 0", e.getMessage());
@ -103,13 +104,13 @@ public class RestClientBuilderTests extends RestClientTestCase {
assertEquals("requestConfigCallback must not be null", e.getMessage()); assertEquals("requestConfigCallback must not be null", e.getMessage());
} }
int numNodes = RandomInts.randomIntBetween(getRandom(), 1, 5); int numNodes = randomIntBetween(1, 5);
HttpHost[] hosts = new HttpHost[numNodes]; HttpHost[] hosts = new HttpHost[numNodes];
for (int i = 0; i < numNodes; i++) { for (int i = 0; i < numNodes; i++) {
hosts[i] = new HttpHost("localhost", 9200 + i); hosts[i] = new HttpHost("localhost", 9200 + i);
} }
RestClientBuilder builder = RestClient.builder(hosts); RestClientBuilder builder = RestClient.builder(hosts);
if (getRandom().nextBoolean()) { if (randomBoolean()) {
builder.setHttpClientConfigCallback(new RestClientBuilder.HttpClientConfigCallback() { builder.setHttpClientConfigCallback(new RestClientBuilder.HttpClientConfigCallback() {
@Override @Override
public HttpAsyncClientBuilder customizeHttpClient(HttpAsyncClientBuilder httpClientBuilder) { public HttpAsyncClientBuilder customizeHttpClient(HttpAsyncClientBuilder httpClientBuilder) {
@ -117,7 +118,7 @@ public class RestClientBuilderTests extends RestClientTestCase {
} }
}); });
} }
if (getRandom().nextBoolean()) { if (randomBoolean()) {
builder.setRequestConfigCallback(new RestClientBuilder.RequestConfigCallback() { builder.setRequestConfigCallback(new RestClientBuilder.RequestConfigCallback() {
@Override @Override
public RequestConfig.Builder customizeRequestConfig(RequestConfig.Builder requestConfigBuilder) { public RequestConfig.Builder customizeRequestConfig(RequestConfig.Builder requestConfigBuilder) {
@ -125,19 +126,55 @@ public class RestClientBuilderTests extends RestClientTestCase {
} }
}); });
} }
if (getRandom().nextBoolean()) { if (randomBoolean()) {
int numHeaders = RandomInts.randomIntBetween(getRandom(), 1, 5); int numHeaders = randomIntBetween(1, 5);
Header[] headers = new Header[numHeaders]; Header[] headers = new Header[numHeaders];
for (int i = 0; i < numHeaders; i++) { for (int i = 0; i < numHeaders; i++) {
headers[i] = new BasicHeader("header" + i, "value"); headers[i] = new BasicHeader("header" + i, "value");
} }
builder.setDefaultHeaders(headers); builder.setDefaultHeaders(headers);
} }
if (getRandom().nextBoolean()) { if (randomBoolean()) {
builder.setMaxRetryTimeoutMillis(RandomInts.randomIntBetween(getRandom(), 1, Integer.MAX_VALUE)); builder.setMaxRetryTimeoutMillis(randomIntBetween(1, Integer.MAX_VALUE));
}
if (randomBoolean()) {
String pathPrefix = (randomBoolean() ? "/" : "") + randomAsciiOfLengthBetween(2, 5);
while (pathPrefix.length() < 20 && randomBoolean()) {
pathPrefix += "/" + randomAsciiOfLengthBetween(3, 6);
}
builder.setPathPrefix(pathPrefix + (randomBoolean() ? "/" : ""));
} }
try (RestClient restClient = builder.build()) { try (RestClient restClient = builder.build()) {
assertNotNull(restClient); assertNotNull(restClient);
} }
} }
public void testSetPathPrefixNull() {
try {
RestClient.builder(new HttpHost("localhost", 9200)).setPathPrefix(null);
fail("pathPrefix set to null should fail!");
} catch (final NullPointerException e) {
assertEquals("pathPrefix must not be null", e.getMessage());
}
}
public void testSetPathPrefixEmpty() {
assertSetPathPrefixThrows("/");
assertSetPathPrefixThrows("");
}
public void testSetPathPrefixMalformed() {
assertSetPathPrefixThrows("//");
assertSetPathPrefixThrows("base/path//");
}
private static void assertSetPathPrefixThrows(final String pathPrefix) {
try {
RestClient.builder(new HttpHost("localhost", 9200)).setPathPrefix(pathPrefix);
fail("path prefix [" + pathPrefix + "] should have failed");
} catch (final IllegalArgumentException e) {
assertThat(e.getMessage(), containsString(pathPrefix));
}
}
} }

View File

@ -22,6 +22,7 @@ package org.elasticsearch.client;
import com.carrotsearch.randomizedtesting.generators.RandomInts; import com.carrotsearch.randomizedtesting.generators.RandomInts;
import com.carrotsearch.randomizedtesting.generators.RandomStrings; import com.carrotsearch.randomizedtesting.generators.RandomStrings;
import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpContext;
import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
@ -60,6 +61,7 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThat; import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
/** /**
* Integration test to check interaction between {@link RestClient} and {@link org.apache.http.client.HttpClient}. * Integration test to check interaction between {@link RestClient} and {@link org.apache.http.client.HttpClient}.
@ -205,6 +207,68 @@ public class RestClientIntegTests extends RestClientTestCase {
bodyTest("GET"); bodyTest("GET");
} }
/**
* Ensure that pathPrefix works as expected even when the path does not exist.
*/
public void testPathPrefixUnknownPath() throws IOException {
// guarantee no other test setup collides with this one and lets it sneak through
final String uniqueContextSuffix = "/testPathPrefixUnknownPath";
final String pathPrefix = "dne/" + randomAsciiOfLengthBetween(1, 5) + "/";
final int statusCode = randomStatusCode(getRandom());
try (final RestClient client =
RestClient.builder(new HttpHost(httpServer.getAddress().getHostString(), httpServer.getAddress().getPort()))
.setPathPrefix((randomBoolean() ? "/" : "") + pathPrefix).build()) {
for (final String method : getHttpMethods()) {
Response esResponse;
try {
esResponse = client.performRequest(method, "/" + statusCode + uniqueContextSuffix);
if ("HEAD".equals(method) == false) {
fail("only HEAD requests should not throw an exception; 404 is expected");
}
} catch(ResponseException e) {
esResponse = e.getResponse();
}
assertThat(esResponse.getRequestLine().getUri(), equalTo("/" + pathPrefix + statusCode + uniqueContextSuffix));
assertThat(esResponse.getStatusLine().getStatusCode(), equalTo(404));
}
}
}
/**
* Ensure that pathPrefix works as expected.
*/
public void testPathPrefix() throws IOException {
// guarantee no other test setup collides with this one and lets it sneak through
final String uniqueContextSuffix = "/testPathPrefix";
final String pathPrefix = "base/" + randomAsciiOfLengthBetween(1, 5) + "/";
final int statusCode = randomStatusCode(getRandom());
final HttpContext context =
httpServer.createContext("/" + pathPrefix + statusCode + uniqueContextSuffix, new ResponseHandler(statusCode));
try (final RestClient client =
RestClient.builder(new HttpHost(httpServer.getAddress().getHostString(), httpServer.getAddress().getPort()))
.setPathPrefix((randomBoolean() ? "/" : "") + pathPrefix).build()) {
for (final String method : getHttpMethods()) {
Response esResponse;
try {
esResponse = client.performRequest(method, "/" + statusCode + uniqueContextSuffix);
} catch(ResponseException e) {
esResponse = e.getResponse();
}
assertThat(esResponse.getRequestLine().getUri(), equalTo("/" + pathPrefix + statusCode + uniqueContextSuffix));
assertThat(esResponse.getStatusLine().getStatusCode(), equalTo(statusCode));
}
} finally {
httpServer.removeContext(context);
}
}
private void bodyTest(String method) throws IOException { private void bodyTest(String method) throws IOException {
String requestBody = "{ \"field\": \"value\" }"; String requestBody = "{ \"field\": \"value\" }";
StringEntity entity = new StringEntity(requestBody); StringEntity entity = new StringEntity(requestBody);

View File

@ -101,7 +101,7 @@ public class RestClientMultipleHostsTests extends RestClientTestCase {
httpHosts[i] = new HttpHost("localhost", 9200 + i); httpHosts[i] = new HttpHost("localhost", 9200 + i);
} }
failureListener = new HostsTrackingFailureListener(); failureListener = new HostsTrackingFailureListener();
restClient = new RestClient(httpClient, 10000, new Header[0], httpHosts, failureListener); restClient = new RestClient(httpClient, 10000, new Header[0], httpHosts, null, failureListener);
} }
public void testRoundRobinOkStatusCodes() throws IOException { public void testRoundRobinOkStatusCodes() throws IOException {

View File

@ -141,7 +141,7 @@ public class RestClientSingleHostTests extends RestClientTestCase {
} }
httpHost = new HttpHost("localhost", 9200); httpHost = new HttpHost("localhost", 9200);
failureListener = new HostsTrackingFailureListener(); failureListener = new HostsTrackingFailureListener();
restClient = new RestClient(httpClient, 10000, defaultHeaders, new HttpHost[]{httpHost}, failureListener); restClient = new RestClient(httpClient, 10000, defaultHeaders, new HttpHost[]{httpHost}, null, failureListener);
} }
/** /**