This is an automated email from the ASF dual-hosted git repository.
smiklosovic pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra.git
The following commit(s) were added to refs/heads/trunk by this push:
new 4079f06249 Extract reusable HttpService from cloud metadata connector
4079f06249 is described below
commit 4079f062493f636a7fc95439307092fcf4b8f3ac
Author: bharathbikkavolu <[email protected]>
AuthorDate: Sun Sep 27 23:45:42 2026 -0500
Extract reusable HttpService from cloud metadata connector
Extract HTTP transport handling from AbstractCloudMetadataServiceConnector
into a reusable HttpService while preserving caller-specific response
handling.
HttpService provides configurable timeouts, request headers, raw HTTP
responses,
and response body handling for reuse by startup-time HTTP integrations.
patch by Bharath Kumar Bikkavolu; reviewed by Bernardo Botella, Stefan
Miklosovic for CASSANDRA-21195
Co-authored-by: Stefan Miklosovic <[email protected]>
Assisted-by: Claude Code:claude-fable
---
CHANGES.txt | 1 +
.../AbstractCloudMetadataServiceConnector.java | 131 ++-----
.../locator/Ec2MetadataServiceConnector.java | 6 +-
.../org/apache/cassandra/utils/HttpService.java | 402 +++++++++++++++++++++
.../apache/cassandra/locator/Ec2ConnectorTest.java | 4 +-
.../apache/cassandra/utils/HttpServiceTest.java | 203 +++++++++++
6 files changed, 646 insertions(+), 101 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index cf5e2ffd4e..739815c831 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,4 +1,5 @@
7.0
+ * Extract reusable HttpService from cloud metadata connector (CASSANDRA-21195)
* Redact hash and _hash suffixed parameters in system_views.settings
(CASSANDRA-21686)
* Allow CQLSSTableWriter to specify SSTable id generator to use
(CASSANDRA-21012)
* Reject LIKE patterns with a wildcard (%) anywhere other than the start or
end (CASSANDRA-21068)
diff --git
a/src/java/org/apache/cassandra/locator/AbstractCloudMetadataServiceConnector.java
b/src/java/org/apache/cassandra/locator/AbstractCloudMetadataServiceConnector.java
index 75f0cfd247..194ba45b4d 100644
---
a/src/java/org/apache/cassandra/locator/AbstractCloudMetadataServiceConnector.java
+++
b/src/java/org/apache/cassandra/locator/AbstractCloudMetadataServiceConnector.java
@@ -18,68 +18,30 @@
package org.apache.cassandra.locator;
-import java.io.DataInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
-import java.util.Map;
-
-import com.google.common.collect.ImmutableMap;
import org.apache.cassandra.config.CassandraRelevantProperties;
import org.apache.cassandra.config.DurationSpec;
import org.apache.cassandra.exceptions.ConfigurationException;
+import org.apache.cassandra.utils.HttpService;
import static java.lang.String.format;
-import static java.nio.charset.StandardCharsets.UTF_8;
-public abstract class AbstractCloudMetadataServiceConnector
+public abstract class AbstractCloudMetadataServiceConnector extends HttpService
{
public static final String METADATA_URL_PROPERTY = "metadata_url";
public static final String METADATA_REQUEST_TIMEOUT_PROPERTY =
"metadata_request_timeout";
public static final String DEFAULT_METADATA_REQUEST_TIMEOUT = "30s";
-
- protected final String metadataServiceUrl;
- protected final int requestTimeoutMs;
-
private final SnitchProperties properties;
public AbstractCloudMetadataServiceConnector(SnitchProperties
snitchProperties)
{
- this.properties = snitchProperties;
- String parsedMetadataServiceUrl =
properties.get(METADATA_URL_PROPERTY, null);
-
- try
- {
- URL url = new URL(parsedMetadataServiceUrl);
- url.toURI();
-
- this.metadataServiceUrl = parsedMetadataServiceUrl;
- }
- catch (MalformedURLException | IllegalArgumentException |
URISyntaxException ex)
- {
- throw new ConfigurationException(format("Snitch metadata service
URL '%s' is invalid. Please review snitch properties " +
- "defined in the configured
'%s' configuration file.",
- parsedMetadataServiceUrl,
-
CassandraRelevantProperties.CASSANDRA_RACKDC_PROPERTIES.getKey()),
- ex);
- }
-
- String metadataRequestTimeout =
properties.get(METADATA_REQUEST_TIMEOUT_PROPERTY,
DEFAULT_METADATA_REQUEST_TIMEOUT);
+ super(parseServiceUrl(snitchProperties),
+ parseRequestTimeout(snitchProperties));
- try
- {
- this.requestTimeoutMs = new
DurationSpec.IntMillisecondsBound(metadataRequestTimeout).toMilliseconds();
- }
- catch (IllegalArgumentException ex)
- {
- throw new ConfigurationException(format("%s as value of %s is
invalid duration! " + ex.getMessage(),
- metadataRequestTimeout,
-
METADATA_REQUEST_TIMEOUT_PROPERTY));
- }
+ this.properties = snitchProperties;
}
public SnitchProperties getProperties()
@@ -87,79 +49,56 @@ public abstract class AbstractCloudMetadataServiceConnector
return properties;
}
- public final String apiCall(String query) throws IOException
+ @Override
+ public String toString()
{
- return apiCall(metadataServiceUrl, query, "GET", ImmutableMap.of(),
200);
+ return format("%s{%s=%s,%s=%s}", getClass().getName(),
+ METADATA_URL_PROPERTY, serviceUrl,
+ METADATA_REQUEST_TIMEOUT_PROPERTY, requestTimeoutMs);
}
- public final String apiCall(String query, Map<String, String>
extraHeaders) throws IOException
+ public static class DefaultCloudMetadataServiceConnector extends
AbstractCloudMetadataServiceConnector
{
- return apiCall(metadataServiceUrl, query, "GET", extraHeaders, 200);
+ public DefaultCloudMetadataServiceConnector(SnitchProperties
properties)
+ {
+ super(properties);
+ }
}
- public String apiCall(String url,
- String query,
- String method,
- Map<String, String> extraHeaders,
- int expectedResponseCode) throws IOException
+ private static String parseServiceUrl(SnitchProperties snitchProperties)
{
- HttpURLConnection conn = null;
+ String parsedMetadataServiceUrl =
snitchProperties.get(METADATA_URL_PROPERTY, null);
+
try
{
- // Populate the region and zone by introspection, fail if 404 on
metadata
- conn = (HttpURLConnection) new URL(url + query).openConnection();
- extraHeaders.forEach(conn::setRequestProperty);
- conn.setRequestMethod(method);
- conn.setConnectTimeout(requestTimeoutMs);
- if (conn.getResponseCode() != expectedResponseCode)
- throw new HttpException(conn.getResponseCode(),
conn.getResponseMessage());
-
- // Read the information. I wish I could say (String)
conn.getContent() here...
- int cl = conn.getContentLength();
-
- if (cl == -1)
- return null;
+ URL url = new URL(parsedMetadataServiceUrl);
+ url.toURI();
- byte[] b = new byte[cl];
- try (DataInputStream d = new DataInputStream((InputStream)
conn.getContent()))
- {
- d.readFully(b);
- }
- return new String(b, UTF_8);
+ return parsedMetadataServiceUrl;
}
- finally
+ catch (MalformedURLException | IllegalArgumentException |
URISyntaxException ex)
{
- if (conn != null)
- conn.disconnect();
+ throw new ConfigurationException(format("Snitch metadata service
URL '%s' is invalid. Please review snitch properties " +
+ "defined in the configured
'%s' configuration file.",
+ parsedMetadataServiceUrl,
+
CassandraRelevantProperties.CASSANDRA_RACKDC_PROPERTIES.getKey()),
+ ex);
}
}
- @Override
- public String toString()
- {
- return format("%s{%s=%s,%s=%s}", getClass().getName(),
- METADATA_URL_PROPERTY, metadataServiceUrl,
- METADATA_REQUEST_TIMEOUT_PROPERTY, requestTimeoutMs);
- }
-
- public static final class HttpException extends IOException
+ private static int parseRequestTimeout(SnitchProperties snitchProperties)
{
- public final int responseCode;
- public final String responseMessage;
+ String metadataRequestTimeout =
snitchProperties.get(METADATA_REQUEST_TIMEOUT_PROPERTY,
DEFAULT_METADATA_REQUEST_TIMEOUT);
- public HttpException(int responseCode, String responseMessage)
+ try
{
- super("HTTP response code: " + responseCode + " (" +
responseMessage + ')');
- this.responseCode = responseCode;
- this.responseMessage = responseMessage;
+ return new
DurationSpec.IntMillisecondsBound(metadataRequestTimeout).toMilliseconds();
}
- }
-
- public static class DefaultCloudMetadataServiceConnector extends
AbstractCloudMetadataServiceConnector
- {
- public DefaultCloudMetadataServiceConnector(SnitchProperties
properties)
+ catch (IllegalArgumentException ex)
{
- super(properties);
+ throw new ConfigurationException(format("%s as value of %s is
invalid duration! " + ex.getMessage(),
+ metadataRequestTimeout,
+
METADATA_REQUEST_TIMEOUT_PROPERTY));
}
}
}
diff --git
a/src/java/org/apache/cassandra/locator/Ec2MetadataServiceConnector.java
b/src/java/org/apache/cassandra/locator/Ec2MetadataServiceConnector.java
index f975ca95d6..23892e9841 100644
--- a/src/java/org/apache/cassandra/locator/Ec2MetadataServiceConnector.java
+++ b/src/java/org/apache/cassandra/locator/Ec2MetadataServiceConnector.java
@@ -94,7 +94,7 @@ abstract class Ec2MetadataServiceConnector extends
AbstractCloudMetadataServiceC
@Override
public String toString()
{
- return String.format("%s{%s=%s}", V1Connector.class.getName(),
METADATA_URL_PROPERTY, metadataServiceUrl);
+ return String.format("%s{%s=%s}", V1Connector.class.getName(),
METADATA_URL_PROPERTY, serviceUrl);
}
}
@@ -184,7 +184,7 @@ abstract class Ec2MetadataServiceConnector extends
AbstractCloudMetadataServiceC
{
return String.format("%s{%s=%s,%s=%s}",
V2Connector.class.getName(),
- METADATA_URL_PROPERTY, metadataServiceUrl,
+ METADATA_URL_PROPERTY, serviceUrl,
AWS_EC2_METADATA_TOKEN_TTL_SECONDS_HEADER_PROPERTY,
tokenTTL.getSeconds());
}
@@ -198,7 +198,7 @@ abstract class Ec2MetadataServiceConnector extends
AbstractCloudMetadataServiceC
{
try
{
- token = Pair.create(super.apiCall(metadataServiceUrl,
+ token = Pair.create(super.apiCall(serviceUrl,
TOKEN_QUERY,
"PUT",
ImmutableMap.of(AWS_EC2_METADATA_TOKEN_TTL_SECONDS_HEADER,
String.valueOf(tokenTTL.getSeconds())),
diff --git a/src/java/org/apache/cassandra/utils/HttpService.java
b/src/java/org/apache/cassandra/utils/HttpService.java
new file mode 100644
index 0000000000..ed81934365
--- /dev/null
+++ b/src/java/org/apache/cassandra/utils/HttpService.java
@@ -0,0 +1,402 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.cassandra.utils;
+
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.Map;
+
+import com.google.common.collect.ImmutableMap;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+/**
+ * A generic service for making HTTP requests.
+ * This class provides reusable HTTP transport logic for components that need
to retrieve
+ * information from external HTTP services during startup.
+ *
+ * <p>This utility is intended for startup-time operations where synchronous,
blocking HTTP
+ * requests are appropriate. Examples include retrieving cloud metadata or
configuration
+ * information required before Cassandra begins serving requests.
+ *
+ * <p><b>Design principles:</b>
+ * <ul>
+ * <li><b>Synchronous requests only</b> - Requests block until the operation
completes or times out</li>
+ * <li><b>Raw HTTP responses</b> - The execute methods return HTTP responses
without validating the status code</li>
+ * <li><b>Configurable timeouts</b> - Connection and read timeouts are
supplied by the caller</li>
+ * <li><b>Header support</b> - Request headers can be supplied by the
caller</li>
+ * <li><b>Diagnostic logging</b> - Request metadata is logged without
logging header values or response contents</li>
+ * </ul>
+ */
+public abstract class HttpService
+{
+ private static final Logger logger =
LoggerFactory.getLogger(HttpService.class);
+
+ public final String serviceUrl;
+ public final int requestTimeoutMs;
+
+ /**
+ * @param serviceUrl base URL that the no-URL {@code apiCall}
overloads issue their requests against
+ * @param requestTimeoutMs connection timeout applied to those requests,
in milliseconds
+ */
+ public HttpService(String serviceUrl, int requestTimeoutMs)
+ {
+ this.serviceUrl = serviceUrl;
+ this.requestTimeoutMs = requestTimeoutMs;
+ }
+
+ /**
+ * Issues a GET against {@link #serviceUrl} and returns the body,
requiring a 200 response.
+ *
+ * @param query path and query string appended to the service URL
+ * @return the response body
+ * @throws HttpException if the response status is not 200
+ * @throws IOException if the request or response read fails
+ */
+ public String apiCall(String query) throws IOException
+ {
+ return apiCall(serviceUrl, query, "GET", ImmutableMap.of(), 200);
+ }
+
+ /**
+ * Issues a GET against {@link #serviceUrl} with additional headers,
requiring a 200 response.
+ *
+ * @param query path and query string appended to the service URL
+ * @param extraHeaders headers to add to the request
+ * @return the response body
+ * @throws HttpException if the response status is not 200
+ * @throws IOException if the request or response read fails
+ */
+ public String apiCall(String query, Map<String, String> extraHeaders)
throws IOException
+ {
+ return apiCall(serviceUrl, query, "GET", extraHeaders, 200);
+ }
+
+ /**
+ * Issues a request against an explicit URL using this service's
connection timeout.
+ * <p>
+ * The read timeout is left at zero, which {@link
java.net.URLConnection#setReadTimeout(int)} treats as
+ * infinite; supply a {@link HttpConfig} through the other overload to
bound it.
+ *
+ * @param url base URL to request
+ * @param query path and query string appended to the URL
+ * @param method HTTP method to use
+ * @param extraHeaders headers to add to the request
+ * @param expectedResponseCode status the response must carry
+ * @return the response body
+ * @throws HttpException if the response status differs from {@code
expectedResponseCode}
+ * @throws IOException if the request or response read fails
+ */
+ public String apiCall(String url,
+ String query,
+ String method,
+ Map<String, String> extraHeaders,
+ int expectedResponseCode) throws IOException
+ {
+ return apiCall(url, query, method, expectedResponseCode,
+ new HttpConfig(requestTimeoutMs,
+ 0,
+ extraHeaders));
+ }
+
+ /**
+ * Issues a request against an explicit URL and asserts the response
status, returning the body.
+ * <p>
+ * Unlike {@link #execute(String, String, HttpConfig)}, which hands back
whatever the server said, this
+ * treats an unexpected status as a failure.
+ *
+ * @param url base URL to request
+ * @param query path and query string appended to the URL
+ * @param method HTTP method to use
+ * @param expectedResponseCode status the response must carry
+ * @param config timeouts and headers for the request
+ * @return the response body
+ * @throws HttpException if the response status differs from {@code
expectedResponseCode}
+ * @throws IOException if the request or response read fails
+ */
+ public String apiCall(String url,
+ String query,
+ String method,
+ int expectedResponseCode,
+ HttpConfig config) throws IOException
+ {
+ HttpResponse response = HttpService.execute(url + query, method,
config);
+
+ if (response.getStatusCode() != expectedResponseCode)
+ throw new HttpException(response.getStatusCode(),
response.getStatusMessage());
+
+ return response.getBody();
+ }
+
+ /**
+ * Configuration for an HTTP request.
+ */
+ public static class HttpConfig
+ {
+ private final int connectTimeoutMs;
+ private final int readTimeoutMs;
+ private final Map<String, String> headers;
+
+ /**
+ * @param connectTimeoutMs connection timeout in milliseconds, zero
meaning no timeout
+ * @param readTimeoutMs read timeout in milliseconds, zero meaning
no timeout
+ * @param headers request headers, or {@code null} for none
+ */
+ public HttpConfig(int connectTimeoutMs, int readTimeoutMs, Map<String,
String> headers)
+ {
+ this.connectTimeoutMs = connectTimeoutMs;
+ this.readTimeoutMs = readTimeoutMs;
+ this.headers = headers != null ? headers : Map.of();
+ }
+
+ /** @return connection timeout in milliseconds, zero meaning no
timeout */
+ public int getConnectTimeoutMs()
+ {
+ return connectTimeoutMs;
+ }
+
+ /** @return read timeout in milliseconds, zero meaning no timeout */
+ public int getReadTimeoutMs()
+ {
+ return readTimeoutMs;
+ }
+
+ /** @return request headers, never {@code null} */
+ public Map<String, String> getHeaders()
+ {
+ return headers;
+ }
+ }
+
+ /**
+ * Raw HTTP response containing the status code, status message, and
response body.
+ * Interpretation of the response is the caller's responsibility.
+ */
+ public static class HttpResponse
+ {
+ private final int statusCode;
+ private final String statusMessage;
+ private final String body;
+
+ /**
+ * @param statusCode HTTP status code
+ * @param statusMessage HTTP reason phrase
+ * @param body response body, or {@code null} if its length
was not known
+ */
+ public HttpResponse(int statusCode, String statusMessage, String body)
+ {
+ this.statusCode = statusCode;
+ this.statusMessage = statusMessage;
+ this.body = body;
+ }
+
+ /** @return HTTP status code */
+ public int getStatusCode()
+ {
+ return statusCode;
+ }
+
+ /** @return HTTP reason phrase */
+ public String getStatusMessage()
+ {
+ return statusMessage;
+ }
+
+ /** @return response body, or {@code null} if the response carried no
known content length */
+ public String getBody()
+ {
+ return body;
+ }
+ }
+
+ /**
+ * Execute a synchronous HTTP request using the specified method.
+ *
+ * <p>This method performs the HTTP transport operation and returns the
response without
+ * interpreting the status code or parsing the response body.
+ *
+ * @param url the URL to request
+ * @param method the HTTP method
+ * @param config the HTTP configuration containing timeouts and headers
+ * @return the raw HTTP response
+ * @throws IOException if the HTTP request or response read fails
+ */
+ public static HttpResponse execute(String url, String method, HttpConfig
config) throws IOException
+ {
+ if (logger.isDebugEnabled())
+ {
+ logger.debug("Executing HTTP {} request to URL: {} with connection
timeout: {}ms, read timeout: {}ms",
+ method,
+ url,
+ config.getConnectTimeoutMs(),
+ config.getReadTimeoutMs());
+ }
+
+ if (logger.isTraceEnabled() && !config.getHeaders().isEmpty())
+ {
+ logger.trace("Request header names: {}",
config.getHeaders().keySet());
+ }
+
+ HttpURLConnection conn = null;
+ long startTime = Clock.Global.currentTimeMillis();
+
+ try
+ {
+ conn = (HttpURLConnection) new URL(url).openConnection();
+ conn.setRequestMethod(method);
+ conn.setConnectTimeout(config.getConnectTimeoutMs());
+ conn.setReadTimeout(config.getReadTimeoutMs());
+
+ for (Map.Entry<String, String> header :
config.getHeaders().entrySet())
+ {
+ conn.setRequestProperty(header.getKey(), header.getValue());
+
+ if (logger.isTraceEnabled())
+ {
+ logger.trace("Setting request header: {}",
header.getKey());
+ }
+ }
+
+ logger.trace("Opening connection to {}", url);
+
+ int statusCode = conn.getResponseCode();
+ String statusMessage = conn.getResponseMessage();
+ long responseTime = Clock.Global.currentTimeMillis() - startTime;
+
+ if (logger.isDebugEnabled())
+ {
+ logger.debug("HTTP {} request to {} completed in {}ms with
status: {} {}",
+ method, url, responseTime, statusCode,
statusMessage);
+ }
+
+ String responseBody = readResponseBody(conn, statusCode);
+
+ if (logger.isTraceEnabled())
+ {
+ int bodyLength = responseBody != null ? responseBody.length()
: 0;
+ logger.trace("Response body length: {} bytes", bodyLength);
+ }
+
+ return new HttpResponse(statusCode, statusMessage, responseBody);
+ }
+ catch (IOException e)
+ {
+ long failureTime = Clock.Global.currentTimeMillis() - startTime;
+ logger.error("HTTP {} request to {} failed after {}ms: {}",
+ method, url, failureTime, e.getMessage(), e);
+ throw e;
+ }
+ finally
+ {
+ if (conn != null)
+ {
+ logger.trace("Disconnecting HTTP connection to {}", url);
+ conn.disconnect();
+ }
+ }
+ }
+
+ /**
+ * Read the HTTP response body using content-length based handling.
+ *
+ * @param conn the HTTP connection
+ * @param statusCode the HTTP response status code
+ * @return the response body as a UTF-8 string, or null if the content
length is -1
+ * @throws IOException if reading the response fails
+ */
+ private static String readResponseBody(HttpURLConnection conn, int
statusCode) throws IOException
+ {
+ int contentLength = conn.getContentLength();
+
+ if (logger.isTraceEnabled())
+ {
+ logger.trace("Reading response body with content length: {}",
contentLength);
+ }
+
+ if (contentLength == -1)
+ {
+ logger.trace("Content length is -1, returning null response body");
+ return null;
+ }
+
+ if (statusCode >= 400)
+ {
+ InputStream errorStream = conn.getErrorStream();
+
+ if (errorStream == null)
+ return null;
+
+ byte[] buffer = new byte[contentLength];
+ int bytesRead = 0;
+
+ try (InputStream inputStream = errorStream)
+ {
+ while (bytesRead < contentLength)
+ {
+ int read = inputStream.read(buffer,
+ bytesRead,
+ contentLength - bytesRead);
+ if (read == -1)
+ break;
+
+ bytesRead += read;
+ }
+ }
+
+ return bytesRead == 0
+ ? null
+ : new String(buffer, 0, bytesRead, UTF_8);
+ }
+ byte[] buffer = new byte[contentLength];
+
+ try (DataInputStream dataInputStream = new
DataInputStream((InputStream) conn.getContent()))
+ {
+ dataInputStream.readFully(buffer);
+ logger.trace("Successfully read {} bytes from response",
contentLength);
+ }
+
+ return new String(buffer, UTF_8);
+ }
+
+ /**
+ * Thrown by the {@code apiCall} methods when the response status is not
the expected one.
+ */
+ public static final class HttpException extends IOException
+ {
+ public final int responseCode;
+ public final String responseMessage;
+
+ /**
+ * @param responseCode status the response carried
+ * @param responseMessage reason phrase the response carried
+ */
+ public HttpException(int responseCode, String responseMessage)
+ {
+ super("HTTP response code: " + responseCode + " (" +
responseMessage + ')');
+ this.responseCode = responseCode;
+ this.responseMessage = responseMessage;
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/unit/org/apache/cassandra/locator/Ec2ConnectorTest.java
b/test/unit/org/apache/cassandra/locator/Ec2ConnectorTest.java
index 756e13cef3..8de912acbb 100644
--- a/test/unit/org/apache/cassandra/locator/Ec2ConnectorTest.java
+++ b/test/unit/org/apache/cassandra/locator/Ec2ConnectorTest.java
@@ -51,7 +51,7 @@ public class Ec2ConnectorTest
assertTrue(ec2Connector instanceof V1Connector);
- assertEquals(DEFAULT_EC2_METADATA_URL,
ec2Connector.metadataServiceUrl);
+ assertEquals(DEFAULT_EC2_METADATA_URL, ec2Connector.serviceUrl);
}
@Test
@@ -61,7 +61,7 @@ public class Ec2ConnectorTest
// v2 connector by default
assertTrue(ec2Connector instanceof V2Connector);
- assertEquals(DEFAULT_EC2_METADATA_URL,
ec2Connector.metadataServiceUrl);
+ assertEquals(DEFAULT_EC2_METADATA_URL, ec2Connector.serviceUrl);
assertEquals(Duration.ofSeconds(MAX_TOKEN_TIME_IN_SECONDS),
((V2Connector) ec2Connector).tokenTTL);
}
diff --git a/test/unit/org/apache/cassandra/utils/HttpServiceTest.java
b/test/unit/org/apache/cassandra/utils/HttpServiceTest.java
new file mode 100644
index 0000000000..1a387993fe
--- /dev/null
+++ b/test/unit/org/apache/cassandra/utils/HttpServiceTest.java
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.cassandra.utils;
+
+import java.net.ProtocolException;
+import java.net.SocketTimeoutException;
+import java.util.Map;
+
+import com.github.tomakehurst.wiremock.junit.WireMockRule;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+import org.apache.cassandra.utils.HttpService.HttpConfig;
+import org.apache.cassandra.utils.HttpService.HttpResponse;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.put;
+import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static
com.github.tomakehurst.wiremock.core.Options.ChunkedEncodingPolicy.ALWAYS;
+import static
com.github.tomakehurst.wiremock.core.Options.ChunkedEncodingPolicy.NEVER;
+import static
com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class HttpServiceTest
+{
+ private static final int CONNECT_TIMEOUT_MS = 5000;
+ private static final int READ_TIMEOUT_MS = 5000;
+
+ @Rule
+ // WireMock chunks responses by default, which HttpService reports as an
unknown-length body (null). Force
+ // Content-Length so the body-bearing tests exercise the normal path; the
chunked case opts back in below.
+ public final WireMockRule service = new
WireMockRule(wireMockConfig().bindAddress("127.0.0.1")
+
.dynamicPort()
+
.useChunkedTransferEncoding(NEVER));
+
+ // a second server that always chunks, so a response with no
Content-Length can be exercised
+ @Rule
+ public final WireMockRule chunkedService = new
WireMockRule(wireMockConfig().bindAddress("127.0.0.1")
+
.dynamicPort()
+
.useChunkedTransferEncoding(ALWAYS));
+
+ private String baseUrl;
+ private String chunkedBaseUrl;
+
+ @Before
+ public void setup()
+ {
+ baseUrl = "http://127.0.0.1:" + service.port();
+ chunkedBaseUrl = "http://127.0.0.1:" + chunkedService.port();
+ }
+
+ @Test
+ public void testGet() throws Exception
+ {
+ service.stubFor(get(urlEqualTo("/get")).willReturn(textResponse(200,
"test-response")));
+
+ HttpResponse response = HttpService.execute(baseUrl + "/get", "GET",
defaultConfig());
+
+ assertEquals(200, response.getStatusCode());
+ assertEquals("OK", response.getStatusMessage());
+ assertEquals("test-response", response.getBody());
+
+ service.verify(getRequestedFor(urlEqualTo("/get")));
+ }
+
+ @Test
+ public void testPut() throws Exception
+ {
+ service.stubFor(put(urlEqualTo("/put")).willReturn(textResponse(200,
"test-token")));
+
+ HttpResponse response = HttpService.execute(baseUrl + "/put", "PUT",
defaultConfig());
+
+ assertEquals(200, response.getStatusCode());
+ assertEquals("test-token", response.getBody());
+
+ service.verify(putRequestedFor(urlEqualTo("/put")));
+ }
+
+ @Test
+ public void testHeaders() throws Exception
+ {
+ // the stub only matches when the header is present, so a missing
header would 404 rather than 200
+ service.stubFor(get(urlEqualTo("/headers"))
+ .withHeader("X-Test-Header", equalTo("test-value"))
+ .willReturn(textResponse(200, "success")));
+
+ HttpConfig config = new HttpConfig(CONNECT_TIMEOUT_MS,
+ READ_TIMEOUT_MS,
+ Map.of("X-Test-Header",
"test-value"));
+
+ HttpResponse response = HttpService.execute(baseUrl + "/headers",
"GET", config);
+
+ assertEquals(200, response.getStatusCode());
+ assertEquals("success", response.getBody());
+ }
+
+ @Test
+ public void testRawResponseStatus() throws Exception
+ {
+
service.stubFor(get(urlEqualTo("/status")).willReturn(textResponse(201,
"created")));
+
+ HttpResponse response = HttpService.execute(baseUrl + "/status",
"GET", defaultConfig());
+
+ assertEquals(201, response.getStatusCode());
+ assertEquals("Created", response.getStatusMessage());
+ assertEquals("created", response.getBody());
+ }
+
+ @Test
+ public void testReadTimeout()
+ {
+ service.stubFor(get(urlEqualTo("/timeout"))
+ .willReturn(textResponse(200, "delayed-response")
+ .withFixedDelay(1000)));
+
+ HttpConfig config = new HttpConfig(CONNECT_TIMEOUT_MS, 100, Map.of());
+
+ assertThatThrownBy(() -> HttpService.execute(baseUrl + "/timeout",
"GET", config))
+ .isInstanceOf(SocketTimeoutException.class);
+ }
+
+ @Test
+ public void testNullHeaders() throws Exception
+ {
+ service.stubFor(get(urlEqualTo("/null-headers"))
+ .willReturn(textResponse(200, "success")));
+
+ HttpConfig config = new HttpConfig(CONNECT_TIMEOUT_MS,
READ_TIMEOUT_MS, null);
+
+ HttpResponse response = HttpService.execute(baseUrl + "/null-headers",
"GET", config);
+
+ assertEquals(200, response.getStatusCode());
+ assertEquals("success", response.getBody());
+ }
+
+ @Test
+ public void testUnknownContentLengthReturnsNullBody() throws Exception
+ {
+ chunkedService.stubFor(get(urlEqualTo("/chunked"))
+ .willReturn(textResponse(200,
"chunked-response")));
+
+ HttpResponse response = HttpService.execute(chunkedBaseUrl +
"/chunked", "GET", defaultConfig());
+
+ assertEquals(200, response.getStatusCode());
+ assertNull(response.getBody());
+ }
+
+ @Test
+ public void testInvalidMethodThrowsIOException()
+ {
+ // rejected by HttpURLConnection before any request is made, so no
stub is needed
+ assertThatThrownBy(() -> HttpService.execute(baseUrl + "/invalid",
"INVALID METHOD", defaultConfig()))
+ .isInstanceOf(ProtocolException.class);
+ }
+
+ @Test
+ public void testErrorResponseBodyIsReturned() throws Exception
+ {
+
service.stubFor(get(urlEqualTo("/unauthorized")).willReturn(textResponse(401,
"Unauthorized")));
+
+ HttpResponse response = HttpService.execute(baseUrl + "/unauthorized",
"GET", defaultConfig());
+
+ assertEquals(401, response.getStatusCode());
+ assertEquals("Unauthorized", response.getStatusMessage());
+ assertEquals("Unauthorized", response.getBody());
+ }
+
+ private HttpConfig defaultConfig()
+ {
+ return new HttpConfig(CONNECT_TIMEOUT_MS, READ_TIMEOUT_MS, Map.of());
+ }
+
+ private static
com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder
textResponse(int status, String body)
+ {
+ return aResponse().withStatus(status)
+ .withHeader("Content-Type", "text/plain;
charset=UTF-8")
+ .withBody(body);
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]