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]

Reply via email to