diff --git a/components/http/http-api/build.gradle.kts b/components/http/http-api/build.gradle.kts
new file mode 100644
index 00000000000..314579b64db
--- /dev/null
+++ b/components/http/http-api/build.gradle.kts
@@ -0,0 +1,35 @@
+plugins {
+ `java-library`
+ `java-test-fixtures`
+}
+
+apply(from = "$rootDir/gradle/java.gradle")
+
+description = "HTTP Client API"
+
+val minimumBranchCoverage by extra(0) // extra(0.7) -- need a library implementation
+val minimumInstructionCoverage by extra(0) // extra(0.7) -- need a library implementation
+
+// Exclude interfaces for test coverage
+val excludedClassesCoverage by extra(
+ listOf(
+ "datadog.http.client.HttpClient",
+ "datadog.http.client.HttpClient.Builder",
+ "datadog.http.client.HttpRequest",
+ "datadog.http.client.HttpRequest.Builder",
+ "datadog.http.client.HttpRequestBody",
+ "datadog.http.client.HttpRequestBody.MultipartBuilder",
+ "datadog.http.client.HttpRequestListener",
+ "datadog.http.client.HttpResponse",
+ "datadog.http.client.HttpUrl",
+ "datadog.http.client.HttpUrl.Builder",
+ )
+)
+
+dependencies {
+ // Add API implementations to test providers
+ // testRuntimeOnly(project(":components:http:http-lib-jdk"))
+ // testRuntimeOnly(project(":components:http:http-lib-okhttp"))
+ // Add MockServer for test fixtures
+ testFixturesImplementation("org.mock-server:mockserver-junit-jupiter-no-dependencies:5.14.0")
+}
diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpClient.java b/components/http/http-api/src/main/java/datadog/http/client/HttpClient.java
new file mode 100644
index 00000000000..2385b4780ec
--- /dev/null
+++ b/components/http/http-api/src/main/java/datadog/http/client/HttpClient.java
@@ -0,0 +1,113 @@
+package datadog.http.client;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.Proxy;
+import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import javax.annotation.Nullable;
+
+/**
+ * This interface is an abstraction for HTTP clients, providing request execution capabilities. This
+ * abstraction is implementation-agnostic and can be backed by third party libraries of the JDK
+ * itself.
+ *
+ *
HttpClient instances should be reused across requests for connection pooling.
+ */
+public interface HttpClient {
+ /**
+ * Executes an HTTP request synchronously and returns the response. The caller is responsible for
+ * closing the response.
+ *
+ * @param request the request to execute
+ * @return the HTTP response
+ * @throws IOException if an I/O error occurs
+ */
+ HttpResponse execute(HttpRequest request) throws IOException;
+
+ /**
+ * Executes an HTTP request asynchronously and returns a {@link CompletableFuture}. The caller is
+ * responsible for closing the response.
+ *
+ * @param request the request to execute
+ * @return a CompletableFuture that completes with the HTTP response
+ */
+ CompletableFuture executeAsync(HttpRequest request);
+
+ /**
+ * Creates a new {@link Builder} for constructing HTTP clients.
+ *
+ * @return a new http client builder
+ */
+ static Builder newBuilder() {
+ return HttpProviders.newClientBuilder();
+ }
+
+ /** Builder for constructing {@link HttpClient} instances. */
+ interface Builder {
+ /**
+ * Sets the client timeouts, including the connection.
+ *
+ * @param timeout the timeout duration
+ * @return this builder
+ */
+ Builder connectTimeout(Duration timeout);
+
+ /**
+ * Sets the proxy configuration.
+ *
+ * @param proxy the proxy to use
+ * @return this builder
+ */
+ Builder proxy(Proxy proxy);
+
+ /**
+ * Sets proxy authentication credentials.
+ *
+ * @param username the proxy username
+ * @param password the proxy password, or {@code null} to use an empty password
+ * @return this builder
+ */
+ Builder proxyAuthenticator(String username, @Nullable String password);
+
+ /**
+ * Configures the client to use a Unix domain socket.
+ *
+ * @param socketFile the Unix domain socket file
+ * @return this builder
+ */
+ Builder unixDomainSocket(File socketFile);
+
+ /**
+ * Configures the client to use a named pipe (Windows).
+ *
+ * @param pipeName the named pipe name
+ * @return this builder
+ */
+ Builder namedPipe(String pipeName);
+
+ /**
+ * Forces clear text (HTTP) connections, disabling TLS.
+ *
+ * @param clearText {@code true} to force HTTP, {@code false} to allow HTTPS
+ * @return this builder
+ */
+ Builder clearText(boolean clearText);
+
+ /**
+ * Sets a custom executor for executing async requests.
+ *
+ * @param executor the executor to use for async requests
+ * @return this builder
+ */
+ Builder executor(Executor executor);
+
+ /**
+ * Builds the {@link HttpClient} with the configured settings.
+ *
+ * @return the constructed HttpClient
+ */
+ HttpClient build();
+ }
+}
diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpProviders.java b/components/http/http-api/src/main/java/datadog/http/client/HttpProviders.java
new file mode 100644
index 00000000000..9358527e2a5
--- /dev/null
+++ b/components/http/http-api/src/main/java/datadog/http/client/HttpProviders.java
@@ -0,0 +1,275 @@
+package datadog.http.client;
+
+import static java.util.Objects.requireNonNull;
+
+import de.thetaphi.forbiddenapis.SuppressForbidden;
+import edu.umd.cs.findbugs.annotations.NonNull;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.util.List;
+
+/**
+ * Factory class providing HTTP client implementations with automatic fallback support.
+ *
+ * This class acts as a provider abstraction layer that dynamically discovers and instantiates
+ * HTTP client implementations at runtime using reflection. It supports two modes of operation: a
+ * default mode using JDK-based HTTP clients and a compatibility mode using OkHttp-based clients.
+ *
+ *
The provider uses lazy initialization and caching of reflection metadata (constructors and
+ * methods) to minimize performance overhead. All cached references are stored in volatile fields to
+ * ensure thread-safe access in concurrent environments.
+ *
+ *
When a component is requested (e.g., client builder, request builder, URL parser), the class
+ * attempts to locate the appropriate implementation by searching for specific class names in the
+ * classpath. If the default implementation is unavailable or if compatibility mode is enabled, it
+ * falls back to alternative implementations.
+ *
+ *
Thread Safety: This class is thread-safe. The volatile fields ensure visibility of cached
+ * reflection metadata across threads, and the lazy initialization pattern is safe for concurrent
+ * access.
+ */
+public final class HttpProviders {
+ private static volatile boolean compatibilityMode = false;
+
+ private static volatile Constructor> HTTP_CLIENT_BUILDER_CONSTRUCTOR;
+ private static volatile Constructor> HTTP_REQUEST_BUILDER_CONSTRUCTOR;
+ private static volatile Constructor> HTTP_URL_BUILDER_CONSTRUCTOR;
+ private static volatile Method HTTP_URL_PARSE_METHOD;
+ private static volatile Method HTTP_URL_FROM_METHOD;
+ private static volatile Method HTTP_REQUEST_BODY_OF_STRING_METHOD;
+ private static volatile Method HTTP_REQUEST_BODY_OF_BYTES_METHOD;
+ private static volatile Method HTTP_REQUEST_BODY_OF_BYTE_BUFFERS_METHOD;
+ private static volatile Method HTTP_REQUEST_BODY_GZIP_METHOD;
+ private static volatile Constructor> HTTP_MULTIPART_BUILDER_CONSTRUCTOR;
+
+ private HttpProviders() {}
+
+ public static void forceCompatClient() {
+ // Skip if already in compat mode
+ if (compatibilityMode) {
+ return;
+ }
+ compatibilityMode = true;
+ // Clear all references to make sure to reload them
+ HTTP_CLIENT_BUILDER_CONSTRUCTOR = null;
+ HTTP_REQUEST_BUILDER_CONSTRUCTOR = null;
+ HTTP_URL_BUILDER_CONSTRUCTOR = null;
+ HTTP_URL_PARSE_METHOD = null;
+ HTTP_URL_FROM_METHOD = null;
+ HTTP_REQUEST_BODY_OF_STRING_METHOD = null;
+ HTTP_REQUEST_BODY_OF_BYTES_METHOD = null;
+ HTTP_REQUEST_BODY_OF_BYTE_BUFFERS_METHOD = null;
+ HTTP_REQUEST_BODY_GZIP_METHOD = null;
+ HTTP_MULTIPART_BUILDER_CONSTRUCTOR = null;
+ }
+
+ static HttpClient.Builder newClientBuilder() {
+ if (HTTP_CLIENT_BUILDER_CONSTRUCTOR == null) {
+ HTTP_CLIENT_BUILDER_CONSTRUCTOR =
+ findConstructor(
+ "datadog.http.client.jdk.JdkHttpClient$Builder",
+ "datadog.http.client.okhttp.OkHttpClient$Builder");
+ }
+ try {
+ return (HttpClient.Builder) HTTP_CLIENT_BUILDER_CONSTRUCTOR.newInstance();
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException("Failed to call constructor", e);
+ }
+ }
+
+ static HttpRequest.Builder newRequestBuilder() {
+ if (HTTP_REQUEST_BUILDER_CONSTRUCTOR == null) {
+ HTTP_REQUEST_BUILDER_CONSTRUCTOR =
+ findConstructor(
+ "datadog.http.client.jdk.JdkHttpRequest$Builder",
+ "datadog.http.client.okhttp.OkHttpRequest$Builder");
+ }
+ try {
+ return (HttpRequest.Builder) HTTP_REQUEST_BUILDER_CONSTRUCTOR.newInstance();
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException("Failed to call constructor", e);
+ }
+ }
+
+ static HttpUrl.Builder newUrlBuilder() {
+ if (HTTP_URL_BUILDER_CONSTRUCTOR == null) {
+ HTTP_URL_BUILDER_CONSTRUCTOR =
+ findConstructor(
+ "datadog.http.client.jdk.JdkHttpUrl$Builder",
+ "datadog.http.client.okhttp.OkHttpUrl$Builder");
+ }
+ try {
+ return (HttpUrl.Builder) HTTP_URL_BUILDER_CONSTRUCTOR.newInstance();
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException("Failed to call constructor", e);
+ }
+ }
+
+ static HttpUrl httpUrlParse(String url) {
+ if (HTTP_URL_PARSE_METHOD == null) {
+ HTTP_URL_PARSE_METHOD =
+ findMethod(
+ "datadog.http.client.jdk.JdkHttpUrl",
+ "datadog.http.client.okhttp.OkHttpUrl",
+ "parse",
+ String.class);
+ }
+ try {
+ return (HttpUrl) HTTP_URL_PARSE_METHOD.invoke(null, url);
+ } catch (ReflectiveOperationException e) {
+ if (e.getCause() instanceof IllegalArgumentException) {
+ throw (IllegalArgumentException) e.getCause();
+ }
+ throw new RuntimeException("Failed to call parse method", e);
+ }
+ }
+
+ static HttpUrl httpUrlFrom(URI uri) {
+ if (HTTP_URL_FROM_METHOD == null) {
+ HTTP_URL_FROM_METHOD =
+ findMethod(
+ "datadog.http.client.jdk.JdkHttpUrl",
+ "datadog.http.client.okhttp.OkHttpUrl",
+ "from",
+ URI.class);
+ }
+ try {
+ return (HttpUrl) HTTP_URL_FROM_METHOD.invoke(null, uri);
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException("Failed to call from method", e);
+ }
+ }
+
+ static HttpRequestBody requestBodyOfString(String content) {
+ requireNonNull(content, "content");
+ if (HTTP_REQUEST_BODY_OF_STRING_METHOD == null) {
+ HTTP_REQUEST_BODY_OF_STRING_METHOD =
+ findMethod(
+ "datadog.http.client.jdk.JdkHttpRequestBody",
+ "datadog.http.client.okhttp.OkHttpRequestBody",
+ "ofString",
+ String.class);
+ }
+ try {
+ return (HttpRequestBody) HTTP_REQUEST_BODY_OF_STRING_METHOD.invoke(null, content);
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException("Failed to call ofString method", e);
+ }
+ }
+
+ static HttpRequestBody requestBodyOfBytes(byte[] bytes) {
+ requireNonNull(bytes, "bytes");
+ if (HTTP_REQUEST_BODY_OF_BYTES_METHOD == null) {
+ HTTP_REQUEST_BODY_OF_BYTES_METHOD =
+ findMethod(
+ "datadog.http.client.jdk.JdkHttpRequestBody",
+ "datadog.http.client.okhttp.OkHttpRequestBody",
+ "ofBytes",
+ byte[].class);
+ }
+ try {
+ return (HttpRequestBody) HTTP_REQUEST_BODY_OF_BYTES_METHOD.invoke(null, (Object) bytes);
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException("Failed to call ofBytes method", e);
+ }
+ }
+
+ static HttpRequestBody requestBodyOfByteBuffers(List buffers) {
+ requireNonNull(buffers, "buffers");
+ if (HTTP_REQUEST_BODY_OF_BYTE_BUFFERS_METHOD == null) {
+ HTTP_REQUEST_BODY_OF_BYTE_BUFFERS_METHOD =
+ findMethod(
+ "datadog.http.client.jdk.JdkHttpRequestBody",
+ "datadog.http.client.okhttp.OkHttpRequestBody",
+ "ofByteBuffers",
+ List.class);
+ }
+ try {
+ return (HttpRequestBody) HTTP_REQUEST_BODY_OF_BYTE_BUFFERS_METHOD.invoke(null, buffers);
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException("Failed to call ofByteBuffers method", e);
+ }
+ }
+
+ static HttpRequestBody requestBodyGzip(HttpRequestBody body) {
+ requireNonNull(body, "body");
+ if (HTTP_REQUEST_BODY_GZIP_METHOD == null) {
+ HTTP_REQUEST_BODY_GZIP_METHOD =
+ findMethod(
+ "datadog.http.client.jdk.JdkHttpRequestBody",
+ "datadog.http.client.okhttp.OkHttpRequestBody",
+ "ofGzip",
+ HttpRequestBody.class);
+ }
+ try {
+ return (HttpRequestBody) HTTP_REQUEST_BODY_GZIP_METHOD.invoke(null, body);
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException("Failed to call ofGzip method", e);
+ }
+ }
+
+ static HttpRequestBody.MultipartBuilder requestBodyMultipart() {
+ if (HTTP_MULTIPART_BUILDER_CONSTRUCTOR == null) {
+ HTTP_MULTIPART_BUILDER_CONSTRUCTOR =
+ findConstructor(
+ "datadog.http.client.jdk.JdkHttpRequestBody$MultipartBuilder",
+ "datadog.http.client.okhttp.OkHttpRequestBody$MultipartBuilder");
+ }
+ try {
+ return (HttpRequestBody.MultipartBuilder) HTTP_MULTIPART_BUILDER_CONSTRUCTOR.newInstance();
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException("Failed to call multipart builder constructor", e);
+ }
+ }
+
+ private static Method findMethod(
+ String defaultClientClass,
+ String compatClientClass,
+ String name,
+ Class>... parameterTypes) {
+ Class> clientClass = findClientClass(defaultClientClass, compatClientClass);
+ try {
+ return clientClass.getMethod(name, parameterTypes);
+ } catch (NoSuchMethodException e) {
+ throw new RuntimeException("Failed to find " + name + " method", e);
+ }
+ }
+
+ private static Constructor> findConstructor(
+ String defaultClientClass, String compatClientClass) {
+ Class> clientClass = findClientClass(defaultClientClass, compatClientClass);
+ try {
+ return clientClass.getConstructor();
+ } catch (NoSuchMethodException e) {
+ throw new RuntimeException("Failed to find constructor", e);
+ }
+ }
+
+ @SuppressForbidden // Class#forName(String) used to dynamically load the http API implementation
+ @NonNull
+ private static Class> findClientClass(String defaultClientClass, String compatClientClass) {
+ Class> clazz = null;
+ // Load the default client class
+ if (!compatibilityMode) {
+ try {
+ clazz = Class.forName(defaultClientClass);
+ } catch (ClassNotFoundException | UnsupportedClassVersionError ignored) {
+ compatibilityMode = true;
+ }
+ }
+ // If not loaded, load the compat client class
+ if (clazz == null) {
+ try {
+ clazz = Class.forName(compatClientClass);
+ } catch (ClassNotFoundException ignored) {
+ }
+ }
+ // If no class loaded, raise the illegal state
+ if (clazz == null) {
+ throw new IllegalStateException("No http client implementation found");
+ }
+ return clazz;
+ }
+}
diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpRequest.java b/components/http/http-api/src/main/java/datadog/http/client/HttpRequest.java
new file mode 100644
index 00000000000..c29a7a2c7f9
--- /dev/null
+++ b/components/http/http-api/src/main/java/datadog/http/client/HttpRequest.java
@@ -0,0 +1,140 @@
+package datadog.http.client;
+
+import java.util.List;
+import javax.annotation.Nullable;
+
+/**
+ * This interface is an abstraction for HTTP requests, providing access to URL, method, headers, and
+ * body.
+ */
+public interface HttpRequest {
+ /* Common headers names and values widely used in HTTP requests */
+ String CONTENT_TYPE = "Content-Type";
+ String APPLICATION_JSON = "application/json; charset=utf-8";
+
+ /**
+ * Returns the request URL.
+ *
+ * @return the HttpUrl
+ */
+ HttpUrl url();
+
+ /**
+ * Returns the HTTP method ({@code GET}, {@code POST}, {@code PUT}, etc.).
+ *
+ * @return the method name
+ */
+ String method();
+
+ /**
+ * Returns the first header value for the given name, or {@code null} if not present.
+ *
+ * @param name the header name
+ * @return the first header value, or {@code null} if not present
+ */
+ @Nullable
+ String header(String name);
+
+ /**
+ * Returns all header values for the given name.
+ *
+ * @param name the header name
+ * @return list of header values, an empty list if not present
+ */
+ List headers(String name);
+
+ /**
+ * Returns the request body, or {@code null} if this request has no body (e.g., GET requests).
+ *
+ * @return the request body, or {@code null} if this request has no body
+ */
+ @Nullable
+ HttpRequestBody body();
+
+ /**
+ * Creates a new {@link Builder} for constructing HTTP requests.
+ *
+ * @return a new builder
+ */
+ static Builder newBuilder() {
+ return HttpProviders.newRequestBuilder();
+ }
+
+ /** Builder for constructing {@link HttpRequest} instances. */
+ interface Builder {
+ /**
+ * Sets the request URL.
+ *
+ * @param url the URL
+ * @return this builder
+ */
+ Builder url(HttpUrl url);
+
+ /**
+ * Sets the request URL from a {@link String}.
+ *
+ * @param url the URL string
+ * @return this builder
+ */
+ Builder url(String url);
+
+ /**
+ * Sets the request method to {@code GET}. This is the default method if other methods are not
+ * set.
+ *
+ * @return this builder
+ */
+ Builder get();
+
+ /**
+ * Sets the request method to {@code POST} with the given body.
+ *
+ * @param body the request body
+ * @return this builder
+ */
+ Builder post(HttpRequestBody body);
+
+ /**
+ * Sets the request method to {@code PUT} with the given body.
+ *
+ * @param body the request body
+ * @return this builder
+ */
+ Builder put(HttpRequestBody body);
+
+ /**
+ * Sets a header, replacing any existing values for the same name.
+ *
+ * @param name the header name
+ * @param value the header value
+ * @return this builder
+ */
+ Builder header(String name, String value);
+
+ /**
+ * Adds a header without removing existing values for the same name.
+ *
+ * @param name the header name
+ * @param value the header value
+ * @return this builder
+ */
+ Builder addHeader(String name, String value);
+
+ /**
+ * Sets the request listener.
+ *
+ * @param listener the listener to notify of request events or {@code null} to remove any
+ * existing listener.
+ * @return this builder
+ */
+ Builder listener(@Nullable HttpRequestListener listener);
+
+ /**
+ * Builds the HttpRequest.
+ *
+ * @return the constructed HttpRequest
+ * @throws IllegalStateException if required fields (like url) are missing
+ */
+ HttpRequest build();
+ }
+}
diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpRequestBody.java b/components/http/http-api/src/main/java/datadog/http/client/HttpRequestBody.java
new file mode 100644
index 00000000000..a67cc90bf61
--- /dev/null
+++ b/components/http/http-api/src/main/java/datadog/http/client/HttpRequestBody.java
@@ -0,0 +1,133 @@
+package datadog.http.client;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * This interface is an abstraction for HTTP request bodies, providing content writing capabilities.
+ * It also offers static factory methods to build common request body types, including gzip
+ * compression and multipart/form-data.
+ */
+public interface HttpRequestBody {
+ /**
+ * Returns the content length in bytes, or {@code -1} if unknown (e.g., for gzipped content).
+ *
+ * @return the content length, or {@code -1} if unknown
+ */
+ long contentLength();
+
+ /**
+ * Writes the body content to the given output stream.
+ *
+ * @param out the output stream to write to
+ * @throws IOException if an I/O error occurs
+ */
+ void writeTo(OutputStream out) throws IOException;
+
+ /**
+ * Creates a request body from a String using UTF-8 encoding. Content-Type should be set via
+ * request headers.
+ *
+ * @param content the string content
+ * @return a new {@link HttpRequestBody}
+ */
+ static HttpRequestBody of(String content) {
+ return HttpProviders.requestBodyOfString(content);
+ }
+
+ /**
+ * Creates a request body from raw bytes. Content-Type should be set via request headers.
+ *
+ * @param bytes the string content
+ * @return a new {@link HttpRequestBody}
+ */
+ static HttpRequestBody of(byte[] bytes) {
+ return HttpProviders.requestBodyOfBytes(bytes);
+ }
+
+ /**
+ * Creates a request body from a list of {@link ByteBuffer}s. Content-Type should be set via
+ * request headers.
+ *
+ * @param buffers the string content
+ * @return a new {@link HttpRequestBody}
+ */
+ static HttpRequestBody of(List buffers) {
+ return HttpProviders.requestBodyOfByteBuffers(buffers);
+ }
+
+ /**
+ * Wraps a request body with gzip compression. The body is compressed eagerly and the content
+ * length reflects the compressed size. Content-Encoding header should be set to "gzip" separately
+ * via request headers.
+ *
+ * @param body the body to compress
+ * @return a new gzip-compressed {@link HttpRequestBody}
+ */
+ static HttpRequestBody gzip(HttpRequestBody body) {
+ return HttpProviders.requestBodyGzip(body);
+ }
+
+ /**
+ * Creates a builder for multipart/form-data request bodies.
+ *
+ * @return a new {@link MultipartBuilder}
+ */
+ static MultipartBuilder multipart() {
+ return HttpProviders.requestBodyMultipart();
+ }
+
+ /**
+ * Builder for creating multipart/form-data request bodies. Implements RFC 7578
+ * multipart/form-data format.
+ */
+ interface MultipartBuilder {
+ /**
+ * Adds a form data part with a text value.
+ *
+ * @param name the field name
+ * @param value the field value
+ * @return this builder
+ */
+ MultipartBuilder addFormDataPart(String name, String value);
+
+ /**
+ * Adds a form data part with a file attachment.
+ *
+ * @param name the field name
+ * @param filename the filename
+ * @param body the file content
+ * @return this builder
+ */
+ MultipartBuilder addFormDataPart(String name, String filename, HttpRequestBody body);
+
+ /**
+ * Adds a part with custom headers (advanced usage). Use this when you need full control over
+ * part headers.
+ *
+ * @param headers map of header name to value (e.g., {@code Content-Disposition}, {@code
+ * Content-Type})
+ * @param body the part content
+ * @return this builder
+ */
+ MultipartBuilder addPart(Map headers, HttpRequestBody body);
+
+ /**
+ * Returns the {@code Content-Type} header value for this multipart body. Includes the boundary
+ * parameter required for parsing. Can be called before or after build().
+ *
+ * @return the content type string (e.g., "multipart/form-data; boundary=...")
+ */
+ String contentType();
+
+ /**
+ * Builds the multipart request body.
+ *
+ * @return the constructed {@link HttpRequestBody}
+ */
+ HttpRequestBody build();
+ }
+}
diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpRequestListener.java b/components/http/http-api/src/main/java/datadog/http/client/HttpRequestListener.java
new file mode 100644
index 00000000000..4d86b99d890
--- /dev/null
+++ b/components/http/http-api/src/main/java/datadog/http/client/HttpRequestListener.java
@@ -0,0 +1,33 @@
+package datadog.http.client;
+
+import java.io.IOException;
+import javax.annotation.Nullable;
+
+/**
+ * This interface represents a listener for HTTP request lifecycle events. Implementations can track
+ * request timing, log requests, or handle errors.
+ */
+public interface HttpRequestListener {
+ /**
+ * Called when a request is about to be sent.
+ *
+ * @param request the request being sent
+ */
+ void onRequestStart(HttpRequest request);
+
+ /**
+ * Called when a response is received successfully.
+ *
+ * @param request the request that was sent
+ * @param response the response received, or {@code null} if response body hasn't been read yet
+ */
+ void onRequestEnd(HttpRequest request, @Nullable HttpResponse response);
+
+ /**
+ * Called when a request fails with an exception.
+ *
+ * @param request the request that failed
+ * @param exception the exception that occurred
+ */
+ void onRequestFailure(HttpRequest request, IOException exception);
+}
diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpResponse.java b/components/http/http-api/src/main/java/datadog/http/client/HttpResponse.java
new file mode 100644
index 00000000000..9c294181f59
--- /dev/null
+++ b/components/http/http-api/src/main/java/datadog/http/client/HttpResponse.java
@@ -0,0 +1,72 @@
+package datadog.http.client;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.Set;
+import javax.annotation.Nullable;
+
+/**
+ * This interface is an abstraction for HTTP responses, providing access to status code, headers,
+ * and body.
+ *
+ * HttpResponse instances must be closed after use to release resources.
+ */
+public interface HttpResponse {
+
+ /**
+ * Returns the HTTP status code.
+ *
+ * @return the status code (e.g., 200, 404, 500)
+ */
+ int code();
+
+ /**
+ * Check whether the response code is in [200..300), indicating the request was successful.
+ *
+ * @return {@code true} if successful, {@code false} otherwise
+ */
+ boolean isSuccessful();
+
+ /**
+ * Returns the first header value for the given name, or {@code null} if not present. Header names
+ * are case-insensitive.
+ *
+ * @param name the header name
+ * @return the first header value, or {@code null} if not present
+ */
+ @Nullable
+ String header(String name);
+
+ /**
+ * Returns all header values for the given name. Header names are case-insensitive.
+ *
+ * @param name the header name
+ * @return list of header values, an empty list if not present
+ */
+ List headers(String name);
+
+ /**
+ * Returns all header names in this response. Header names are returned in their canonical form.
+ *
+ * @return set of header names, empty if no headers present
+ */
+ Set headerNames();
+
+ /**
+ * Returns the response body as an {@link InputStream}. The caller is responsible for closing the
+ * stream.
+ *
+ * @return the response body stream
+ */
+ InputStream body();
+
+ /**
+ * Returns the response body as a {@link String} using {@code Content-Type} charset or UTF-8 if
+ * absent.
+ *
+ * @return the response body as a {@link String}
+ * @throws IOException if an I/O error occurs
+ */
+ String bodyAsString() throws IOException;
+}
diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpUrl.java b/components/http/http-api/src/main/java/datadog/http/client/HttpUrl.java
new file mode 100644
index 00000000000..2674119235e
--- /dev/null
+++ b/components/http/http-api/src/main/java/datadog/http/client/HttpUrl.java
@@ -0,0 +1,141 @@
+package datadog.http.client;
+
+import static java.util.Objects.requireNonNull;
+
+import java.net.URI;
+import javax.annotation.Nullable;
+
+/**
+ * This interface is an abstraction for HTTP URLs, providing URL parsing, building, and manipulation
+ * capabilities. It also offers static factory methods to build URLs from JDK URIs using {@link
+ * #from(URI)}, or parse from strings using {@link #parse(String)}.
+ */
+public interface HttpUrl {
+ /**
+ * Returns the complete URL as a string.
+ *
+ * @return the URL string
+ */
+ String url();
+
+ /**
+ * Returns the scheme (protocol) of this URL.
+ *
+ * @return the scheme (e.g., "http", "https")
+ */
+ String scheme();
+
+ /**
+ * Returns the host of this URL.
+ *
+ * @return the host name or IP address
+ */
+ String host();
+
+ /**
+ * Returns the port of this URL. Returns the default port for the scheme if not explicitly set (80
+ * for http, 443 for https).
+ *
+ * @return the port number
+ */
+ int port();
+
+ /**
+ * Resolves a relative URL against this URL.
+ *
+ * @param path the relative path to resolve
+ * @return a new {@link HttpUrl} with the resolved path
+ */
+ HttpUrl resolve(String path);
+
+ /**
+ * Returns a {@link Builder} to modify this URL.
+ *
+ * @return a new {@link Builder} based on this URL
+ */
+ Builder newBuilder();
+
+ /**
+ * Parses a URL string into an {@link HttpUrl}.
+ *
+ * @param url the URL string to parse
+ * @return the parsed {@link HttpUrl}
+ * @throws IllegalArgumentException if the URL is malformed
+ */
+ static HttpUrl parse(String url) throws IllegalArgumentException {
+ requireNonNull(url, "url");
+ return HttpProviders.httpUrlParse(url);
+ }
+
+ /**
+ * Creates an HttpUrl from an {@link URI}.
+ *
+ * @param uri the {@link URI} to get an {@link HttpUrl} from
+ * @return the {@link HttpUrl} related to the URI
+ */
+ static HttpUrl from(URI uri) {
+ requireNonNull(uri, "uri");
+ return HttpProviders.httpUrlFrom(uri);
+ }
+
+ /**
+ * Creates a new {@link Builder}der for constructing URLs.
+ *
+ * @return a new {@link Builder}
+ */
+ static Builder builder() {
+ return HttpProviders.newUrlBuilder();
+ }
+
+ /** Builder for constructing {@link HttpUrl} instances. */
+ interface Builder {
+ /**
+ * Sets the scheme (protocol) for the URL.
+ *
+ * @param scheme the scheme (e.g., "http", "https")
+ * @return this builder
+ */
+ Builder scheme(String scheme);
+
+ /**
+ * Sets the host for the URL.
+ *
+ * @param host the host name or IP address
+ * @return this builder
+ */
+ Builder host(String host);
+
+ /**
+ * Sets the port for the URL.
+ *
+ * @param port the port number
+ * @return this builder
+ */
+ Builder port(int port);
+
+ /**
+ * Adds a path segment to the URL.
+ *
+ * @param segment the path segment to add
+ * @return this builder
+ */
+ Builder addPathSegment(String segment);
+
+ /**
+ * Adds a query parameter to the URL.
+ *
+ * @param name the parameter name
+ * @param value the parameter value
+ * @return this builder
+ */
+ Builder addQueryParameter(String name, @Nullable String value);
+
+ /**
+ * Builds the HttpUrl.
+ *
+ * @return the constructed HttpUrl
+ * @throws IllegalStateException if required fields are missing
+ */
+ HttpUrl build();
+ }
+}
diff --git a/components/http/http-api/src/main/java/datadog/http/client/package-info.java b/components/http/http-api/src/main/java/datadog/http/client/package-info.java
new file mode 100644
index 00000000000..c56aab93967
--- /dev/null
+++ b/components/http/http-api/src/main/java/datadog/http/client/package-info.java
@@ -0,0 +1,4 @@
+@ParametersAreNonnullByDefault
+package datadog.http.client;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/components/http/http-api/src/test/java/datadog/http/client/HttpProvidersTest.java b/components/http/http-api/src/test/java/datadog/http/client/HttpProvidersTest.java
new file mode 100644
index 00000000000..45da8ec6707
--- /dev/null
+++ b/components/http/http-api/src/test/java/datadog/http/client/HttpProvidersTest.java
@@ -0,0 +1,163 @@
+package datadog.http.client;
+
+import static java.util.Collections.emptyList;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.condition.JRE.JAVA_10;
+import static org.junit.jupiter.api.condition.JRE.JAVA_11;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+import java.net.URI;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+abstract class HttpProvidersTest {
+ private static final String URL_EXAMPLE = "http://localhost";
+ private static final String CONTENT_EXAMPLE = "content";
+
+ abstract String getImplementationPackage();
+
+ @ParameterizedTest(name = "[{index}] {0} builder")
+ @MethodSource("builders")
+ void testNewBuilder(String type, Supplier> builderSupplier) {
+ Object builder = builderSupplier.get();
+ assertType(builder);
+ }
+
+ static Stream builders() {
+ return Stream.of(
+ arguments("client", (Supplier>) HttpProviders::newClientBuilder),
+ arguments("request", (Supplier>) HttpProviders::newRequestBuilder),
+ arguments("url", (Supplier>) HttpProviders::newUrlBuilder));
+ }
+
+ @Test
+ void testHttpUrlParse() {
+ HttpUrl url = HttpProviders.httpUrlParse(URL_EXAMPLE);
+ assertType(url);
+ }
+
+ @Test
+ void testHttpUrlParseInvalidUrl() {
+ // An invalid URL causes the underlying parse() to throw IllegalArgumentException,
+ // wrapped as InvocationTargetException. HttpProviders unwraps and re-throws it.
+ assertThrows(
+ IllegalArgumentException.class, () -> HttpProviders.httpUrlParse("not a valid url"));
+ }
+
+ @Test
+ void testHttpUrlFromUri() {
+ HttpUrl url = HttpProviders.httpUrlFrom(URI.create(URL_EXAMPLE));
+ assertType(url);
+ }
+
+ @Test
+ void testRequestBodyOfString() {
+ HttpRequestBody body = HttpProviders.requestBodyOfString(CONTENT_EXAMPLE);
+ assertType(body);
+ }
+
+ @Test
+ void testRequestBodyOfBytes() {
+ HttpRequestBody body = HttpProviders.requestBodyOfBytes(new byte[0]);
+ assertType(body);
+ }
+
+ @Test
+ void testRequestBodyOfByteBuffers() {
+ HttpRequestBody body = HttpProviders.requestBodyOfByteBuffers(emptyList());
+ assertType(body);
+ }
+
+ @Test
+ void testRequestBodyGzip() {
+ HttpRequestBody body =
+ HttpProviders.requestBodyGzip(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE));
+ assertType(body);
+ }
+
+ @Test
+ void testRequestBodyMultipart() {
+ HttpRequestBody.MultipartBuilder builder = HttpProviders.requestBodyMultipart();
+ assertType(builder);
+ }
+
+ @Test
+ void testCachedProviders() {
+ // First calls — populate all lazy-init caches
+ HttpProviders.newClientBuilder();
+ HttpProviders.newRequestBuilder();
+ HttpProviders.newUrlBuilder();
+ HttpProviders.httpUrlParse(URL_EXAMPLE);
+ HttpProviders.httpUrlFrom(URI.create(URL_EXAMPLE));
+ HttpProviders.requestBodyOfString(CONTENT_EXAMPLE);
+ HttpProviders.requestBodyOfBytes(new byte[0]);
+ HttpProviders.requestBodyOfByteBuffers(emptyList());
+ HttpProviders.requestBodyGzip(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE));
+ HttpProviders.requestBodyMultipart();
+ // Second calls — hit the non-null (cached) branch for every lazy field
+ assertNotNull(HttpProviders.newClientBuilder());
+ assertNotNull(HttpProviders.newRequestBuilder());
+ assertNotNull(HttpProviders.newUrlBuilder());
+ assertNotNull(HttpProviders.httpUrlParse(URL_EXAMPLE));
+ assertNotNull(HttpProviders.httpUrlFrom(URI.create(URL_EXAMPLE)));
+ assertNotNull(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE));
+ assertNotNull(HttpProviders.requestBodyOfBytes(new byte[0]));
+ assertNotNull(HttpProviders.requestBodyOfByteBuffers(emptyList()));
+ assertNotNull(
+ HttpProviders.requestBodyGzip(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE)));
+ assertNotNull(HttpProviders.requestBodyMultipart());
+ }
+
+ private void assertType(Object builder) {
+ assertNotNull(builder);
+ assertTrue(builder.getClass().getName().startsWith(getImplementationPackage()));
+ }
+
+ @Disabled
+ @EnabledForJreRange(max = JAVA_10)
+ static class OkHttpProvidersForkedTest extends HttpProvidersTest {
+ @Override
+ String getImplementationPackage() {
+ return "datadog.http.client.okhttp";
+ }
+ }
+
+ @Disabled
+ @EnabledForJreRange(min = JAVA_11)
+ static class JdkHttpProvidersForkedTest extends HttpProvidersTest {
+ @Override
+ String getImplementationPackage() {
+ return "datadog.http.client.jdk";
+ }
+ }
+
+ @Disabled
+ @EnabledForJreRange(min = JAVA_11)
+ static class HttpProvidersCompatForkedTest extends HttpProvidersTest {
+ @BeforeAll
+ static void beforeAll() {
+ HttpProviders.forceCompatClient();
+ }
+
+ @Override
+ String getImplementationPackage() {
+ return "datadog.http.client.okhttp";
+ }
+
+ @Test
+ void testForceCompatClientIsIdempotent() {
+ // compatibilityMode is already true — second call must hit early return (no NPE, no reset)
+ HttpProviders.forceCompatClient();
+ assertNotNull(HttpProviders.newClientBuilder());
+ }
+ }
+}
diff --git a/components/http/http-api/src/test/test/java/datadog/http/client/HttpProvidersTest.java b/components/http/http-api/src/test/test/java/datadog/http/client/HttpProvidersTest.java
new file mode 100644
index 00000000000..d337299c1d6
--- /dev/null
+++ b/components/http/http-api/src/test/test/java/datadog/http/client/HttpProvidersTest.java
@@ -0,0 +1,159 @@
+package datadog.http.client;
+
+import static java.util.Collections.emptyList;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.condition.JRE.JAVA_10;
+import static org.junit.jupiter.api.condition.JRE.JAVA_11;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+import java.net.URI;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+abstract class HttpProvidersTest {
+ private static final String URL_EXAMPLE = "http://localhost";
+ private static final String CONTENT_EXAMPLE = "content";
+
+ abstract String getImplementationPackage();
+
+ @ParameterizedTest(name = "[{index}] {0} builder")
+ @MethodSource("builders")
+ void testNewBuilder(String type, Supplier> builderSupplier) {
+ Object builder = builderSupplier.get();
+ assertType(builder);
+ }
+
+ static Stream builders() {
+ return Stream.of(
+ arguments("client", (Supplier>) HttpProviders::newClientBuilder),
+ arguments("request", (Supplier>) HttpProviders::newRequestBuilder),
+ arguments("url", (Supplier>) HttpProviders::newUrlBuilder));
+ }
+
+ @Test
+ void testHttpUrlParse() {
+ HttpUrl url = HttpProviders.httpUrlParse(URL_EXAMPLE);
+ assertType(url);
+ }
+
+ @Test
+ void testHttpUrlParseInvalidUrl() {
+ // An invalid URL causes the underlying parse() to throw IllegalArgumentException,
+ // wrapped as InvocationTargetException. HttpProviders unwraps and re-throws it.
+ assertThrows(
+ IllegalArgumentException.class, () -> HttpProviders.httpUrlParse("not a valid url"));
+ }
+
+ @Test
+ void testHttpUrlFromUri() {
+ HttpUrl url = HttpProviders.httpUrlFrom(URI.create(URL_EXAMPLE));
+ assertType(url);
+ }
+
+ @Test
+ void testRequestBodyOfString() {
+ HttpRequestBody body = HttpProviders.requestBodyOfString(CONTENT_EXAMPLE);
+ assertType(body);
+ }
+
+ @Test
+ void testRequestBodyOfBytes() {
+ HttpRequestBody body = HttpProviders.requestBodyOfBytes(new byte[0]);
+ assertType(body);
+ }
+
+ @Test
+ void testRequestBodyOfByteBuffers() {
+ HttpRequestBody body = HttpProviders.requestBodyOfByteBuffers(emptyList());
+ assertType(body);
+ }
+
+ @Test
+ void testRequestBodyGzip() {
+ HttpRequestBody body =
+ HttpProviders.requestBodyGzip(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE));
+ assertType(body);
+ }
+
+ @Test
+ void testRequestBodyMultipart() {
+ HttpRequestBody.MultipartBuilder builder = HttpProviders.requestBodyMultipart();
+ assertType(builder);
+ }
+
+ @Test
+ void testCachedProviders() {
+ // First calls — populate all lazy-init caches
+ HttpProviders.newClientBuilder();
+ HttpProviders.newRequestBuilder();
+ HttpProviders.newUrlBuilder();
+ HttpProviders.httpUrlParse(URL_EXAMPLE);
+ HttpProviders.httpUrlFrom(URI.create(URL_EXAMPLE));
+ HttpProviders.requestBodyOfString(CONTENT_EXAMPLE);
+ HttpProviders.requestBodyOfBytes(new byte[0]);
+ HttpProviders.requestBodyOfByteBuffers(emptyList());
+ HttpProviders.requestBodyGzip(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE));
+ HttpProviders.requestBodyMultipart();
+ // Second calls — hit the non-null (cached) branch for every lazy field
+ assertNotNull(HttpProviders.newClientBuilder());
+ assertNotNull(HttpProviders.newRequestBuilder());
+ assertNotNull(HttpProviders.newUrlBuilder());
+ assertNotNull(HttpProviders.httpUrlParse(URL_EXAMPLE));
+ assertNotNull(HttpProviders.httpUrlFrom(URI.create(URL_EXAMPLE)));
+ assertNotNull(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE));
+ assertNotNull(HttpProviders.requestBodyOfBytes(new byte[0]));
+ assertNotNull(HttpProviders.requestBodyOfByteBuffers(emptyList()));
+ assertNotNull(
+ HttpProviders.requestBodyGzip(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE)));
+ assertNotNull(HttpProviders.requestBodyMultipart());
+ }
+
+ private void assertType(Object builder) {
+ assertNotNull(builder);
+ assertTrue(builder.getClass().getName().startsWith(getImplementationPackage()));
+ }
+
+ @EnabledForJreRange(max = JAVA_10)
+ static class OkHttpProvidersForkedTest extends HttpProvidersTest {
+ @Override
+ String getImplementationPackage() {
+ return "datadog.http.client.okhttp";
+ }
+ }
+
+ @EnabledForJreRange(min = JAVA_11)
+ static class JdkHttpProvidersForkedTest extends HttpProvidersTest {
+ @Override
+ String getImplementationPackage() {
+ return "datadog.http.client.jdk";
+ }
+ }
+
+ @EnabledForJreRange(min = JAVA_11)
+ static class HttpProvidersCompatForkedTest extends HttpProvidersTest {
+ @BeforeAll
+ static void beforeAll() {
+ HttpProviders.forceCompatClient();
+ }
+
+ @Override
+ String getImplementationPackage() {
+ return "datadog.http.client.okhttp";
+ }
+
+ @Test
+ void testForceCompatClientIsIdempotent() {
+ // compatibilityMode is already true — second call must hit early return (no NPE, no reset)
+ HttpProviders.forceCompatClient();
+ assertNotNull(HttpProviders.newClientBuilder());
+ }
+ }
+}
diff --git a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpClientAsyncTest.java b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpClientAsyncTest.java
new file mode 100644
index 00000000000..30c2df16168
--- /dev/null
+++ b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpClientAsyncTest.java
@@ -0,0 +1,241 @@
+package datadog.http.client;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
+
+import java.io.IOException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockserver.integration.ClientAndServer;
+import org.mockserver.junit.jupiter.MockServerExtension;
+
+@ExtendWith(MockServerExtension.class)
+public class HttpClientAsyncTest {
+ private static final int TIMEOUT_SECONDS = 5;
+ private ClientAndServer server;
+ private HttpClient client;
+ private String baseUrl;
+
+ @BeforeEach
+ void setUp(ClientAndServer server) {
+ this.server = server;
+ this.client = HttpClient.newBuilder().build();
+ this.baseUrl = "http://localhost:" + server.getPort();
+ }
+
+ @AfterEach
+ void tearDown() {
+ this.server.reset();
+ }
+
+ @Test
+ void testExecuteAsyncSuccess() throws Exception {
+ org.mockserver.model.HttpRequest expectedRequest =
+ request().withMethod("GET").withPath("/test");
+ this.server.when(expectedRequest).respond(response().withStatusCode(200).withBody("success"));
+
+ HttpUrl url = HttpUrl.parse(this.baseUrl + "/test");
+ HttpRequest request = HttpRequest.newBuilder().url(url).get().build();
+
+ CompletableFuture future = this.client.executeAsync(request);
+
+ HttpResponse response = future.get(TIMEOUT_SECONDS, SECONDS);
+ assertNotNull(response);
+ assertEquals(200, response.code());
+ assertTrue(response.isSuccessful());
+ assertEquals("success", response.bodyAsString());
+
+ this.server.verify(expectedRequest);
+ }
+
+ @Test
+ void testExecuteAsyncHttpError() throws Exception {
+ org.mockserver.model.HttpRequest expectedRequest =
+ request().withMethod("GET").withPath("/notfound");
+ this.server.when(expectedRequest).respond(response().withStatusCode(404));
+
+ HttpUrl url = HttpUrl.parse(this.baseUrl + "/notfound");
+ HttpRequest request = HttpRequest.newBuilder().url(url).get().build();
+
+ CompletableFuture future = this.client.executeAsync(request);
+
+ // HTTP errors (4xx, 5xx) should complete normally, not exceptionally
+ HttpResponse response = future.get(TIMEOUT_SECONDS, SECONDS);
+ assertNotNull(response);
+ assertEquals(404, response.code());
+
+ this.server.verify(expectedRequest);
+ }
+
+ @Test
+ void testExecuteAsyncWithListener() throws Exception {
+ org.mockserver.model.HttpRequest expectedRequest =
+ request().withMethod("GET").withPath("/test");
+ this.server.when(expectedRequest).respond(response().withStatusCode(200));
+
+ AtomicBoolean startCalled = new AtomicBoolean(false);
+ AtomicBoolean endCalled = new AtomicBoolean(false);
+ AtomicReference capturedResponse = new AtomicReference<>();
+ CountDownLatch latch = new CountDownLatch(1);
+
+ HttpUrl url = HttpUrl.parse(this.baseUrl + "/test");
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .url(url)
+ .get()
+ .listener(
+ new HttpRequestListener() {
+ @Override
+ public void onRequestStart(HttpRequest request) {
+ startCalled.set(true);
+ }
+
+ @Override
+ public void onRequestEnd(HttpRequest request, HttpResponse response) {
+ endCalled.set(true);
+ capturedResponse.set(response);
+ latch.countDown();
+ }
+
+ @Override
+ public void onRequestFailure(HttpRequest request, IOException exception) {
+ fail("Should not fail");
+ }
+ })
+ .build();
+
+ this.client.executeAsync(request);
+
+ assertTrue(latch.await(TIMEOUT_SECONDS, SECONDS), "Listener should be called");
+ assertTrue(startCalled.get(), "onRequestStart should be called");
+ assertTrue(endCalled.get(), "onRequestEnd should be called");
+ assertNotNull(capturedResponse.get());
+ assertEquals(200, capturedResponse.get().code());
+
+ this.server.verify(expectedRequest);
+ }
+
+ @Test
+ void testExecuteAsyncWithListenerOnFailure() throws Exception {
+ // Use an invalid port to cause connection failure
+ HttpUrl url = HttpUrl.parse("http://localhost:1/test");
+
+ AtomicBoolean startCalled = new AtomicBoolean(false);
+ AtomicBoolean failureCalled = new AtomicBoolean(false);
+ AtomicReference capturedException = new AtomicReference<>();
+ CountDownLatch latch = new CountDownLatch(1);
+
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .url(url)
+ .get()
+ .listener(
+ new HttpRequestListener() {
+ @Override
+ public void onRequestStart(HttpRequest request) {
+ startCalled.set(true);
+ }
+
+ @Override
+ public void onRequestEnd(HttpRequest request, HttpResponse response) {
+ fail("Should not succeed");
+ }
+
+ @Override
+ public void onRequestFailure(HttpRequest request, IOException exception) {
+ failureCalled.set(true);
+ capturedException.set(exception);
+ latch.countDown();
+ }
+ })
+ .build();
+
+ CompletableFuture future = this.client.executeAsync(request);
+
+ assertTrue(latch.await(TIMEOUT_SECONDS, SECONDS), "Listener should be called");
+ assertTrue(startCalled.get(), "onRequestStart should be called");
+ assertTrue(failureCalled.get(), "onRequestFailure should be called");
+ assertNotNull(capturedException.get());
+
+ // The future should also complete exceptionally
+ try {
+ future.get(TIMEOUT_SECONDS, SECONDS);
+ fail("Future should complete exceptionally");
+ } catch (ExecutionException e) {
+ assertInstanceOf(IOException.class, e.getCause());
+ }
+ }
+
+ @Test
+ void testExecuteAsyncComposition() throws Exception {
+ org.mockserver.model.HttpRequest expectedRequest =
+ request().withMethod("GET").withPath("/test");
+ this.server.when(expectedRequest).respond(response().withStatusCode(200).withBody("42"));
+
+ HttpUrl url = HttpUrl.parse(this.baseUrl + "/test");
+ HttpRequest request = HttpRequest.newBuilder().url(url).get().build();
+
+ // Test thenApply composition
+ CompletableFuture future =
+ this.client
+ .executeAsync(request)
+ .thenApply(
+ response -> {
+ try {
+ return Integer.parseInt(response.bodyAsString().trim());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+
+ Integer result = future.get(TIMEOUT_SECONDS, SECONDS);
+ assertEquals(42, result);
+
+ this.server.verify(expectedRequest);
+ }
+
+ @Test
+ void testExecuteAsyncMultipleRequests() throws Exception {
+ org.mockserver.model.HttpRequest expectedRequest1 =
+ request().withMethod("GET").withPath("/test1");
+ org.mockserver.model.HttpRequest expectedRequest2 =
+ request().withMethod("GET").withPath("/test2");
+ this.server
+ .when(expectedRequest1)
+ .respond(response().withStatusCode(200).withBody("response1"));
+ this.server
+ .when(expectedRequest2)
+ .respond(response().withStatusCode(200).withBody("response2"));
+
+ HttpRequest request1 =
+ HttpRequest.newBuilder().url(HttpUrl.parse(this.baseUrl + "/test1")).get().build();
+ HttpRequest request2 =
+ HttpRequest.newBuilder().url(HttpUrl.parse(this.baseUrl + "/test2")).get().build();
+
+ // Execute both requests concurrently
+ CompletableFuture future1 = this.client.executeAsync(request1);
+ CompletableFuture future2 = this.client.executeAsync(request2);
+
+ // Wait for both
+ CompletableFuture.allOf(future1, future2).get(TIMEOUT_SECONDS, SECONDS);
+
+ HttpResponse response1 = future1.get();
+ HttpResponse response2 = future2.get();
+
+ assertEquals("response1", response1.bodyAsString());
+ assertEquals("response2", response2.bodyAsString());
+ }
+}
diff --git a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpClientTest.java b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpClientTest.java
new file mode 100644
index 00000000000..811ed742723
--- /dev/null
+++ b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpClientTest.java
@@ -0,0 +1,200 @@
+package datadog.http.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
+
+import java.io.IOException;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockserver.integration.ClientAndServer;
+import org.mockserver.junit.jupiter.MockServerExtension;
+
+@ExtendWith(MockServerExtension.class)
+public class HttpClientTest {
+ private ClientAndServer server;
+ private HttpClient client;
+ private String baseUrl;
+
+ @BeforeEach
+ void setUp(ClientAndServer server) {
+ this.server = server;
+ this.client = HttpClient.newBuilder().build();
+ this.baseUrl = "http://localhost:" + server.getPort();
+ }
+
+ @AfterEach
+ void tearDown() {
+ this.server.reset();
+ }
+
+ @Test
+ void testGetRequest() throws IOException {
+ org.mockserver.model.HttpRequest expectedRequest =
+ request().withMethod("GET").withPath("/test");
+ this.server.when(expectedRequest).respond(response());
+
+ HttpUrl url = HttpUrl.parse(this.baseUrl + "/test");
+ HttpRequest request = HttpRequest.newBuilder().url(url).get().build();
+
+ HttpResponse response = this.client.execute(request);
+
+ assertNotNull(response);
+ assertEquals(200, response.code());
+ assertTrue(response.isSuccessful());
+
+ this.server.verify(expectedRequest);
+ }
+
+ @Test
+ void testPostRequest() throws IOException {
+ String payload = "{\"key\":\"value\"}";
+ org.mockserver.model.HttpRequest expectedRequest =
+ request()
+ .withMethod("POST")
+ .withPath("/test")
+ .withHeader("Content-Type", "application/json")
+ .withBody(payload);
+ this.server.when(expectedRequest).respond(response().withStatusCode(201));
+
+ HttpUrl url = HttpUrl.parse(this.baseUrl + "/test");
+ HttpRequestBody body = HttpRequestBody.of(payload);
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .url(url)
+ .header("Content-Type", "application/json")
+ .post(body)
+ .build();
+
+ HttpResponse response = this.client.execute(request);
+
+ assertNotNull(response);
+ assertEquals(201, response.code());
+ assertTrue(response.isSuccessful());
+
+ this.server.verify(expectedRequest);
+ }
+
+ @Test
+ void testPutRequest() throws IOException {
+ String payload = "{\"key\":\"value\"}";
+ org.mockserver.model.HttpRequest expectedRequest =
+ request()
+ .withMethod("PUT")
+ .withPath("/test")
+ .withHeader("Content-Type", "application/json")
+ .withBody(payload);
+ this.server.when(expectedRequest).respond(response().withStatusCode(200));
+
+ HttpUrl url = HttpUrl.parse(this.baseUrl + "/test");
+ HttpRequestBody body = HttpRequestBody.of(payload);
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .url(url)
+ .header("Content-Type", "application/json")
+ .put(body)
+ .build();
+
+ HttpResponse response = this.client.execute(request);
+
+ assertNotNull(response);
+ assertEquals(200, response.code());
+ assertTrue(response.isSuccessful());
+
+ this.server.verify(expectedRequest);
+ }
+
+ @Test
+ void testErrorResponse() throws IOException {
+ org.mockserver.model.HttpRequest expectedRequest =
+ request().withMethod("GET").withPath("/missing");
+ this.server.when(expectedRequest).respond(response().withStatusCode(404));
+
+ HttpUrl url = HttpUrl.parse(this.baseUrl + "/missing");
+ HttpRequest request = HttpRequest.newBuilder().url(url).get().build();
+
+ HttpResponse response = this.client.execute(request);
+
+ assertNotNull(response);
+ assertEquals(404, response.code());
+ assertFalse(response.isSuccessful());
+
+ this.server.verify(expectedRequest);
+ }
+
+ @Test
+ void testRequestHeaders() throws IOException {
+ org.mockserver.model.HttpRequest expectedRequest =
+ request()
+ .withMethod("GET")
+ .withPath("/test")
+ .withHeader("Accept", "text/plain")
+ .withHeader("X-Custom-Header", "custom-value1", "custom-value2", "custom-value3");
+ this.server.when(expectedRequest).respond(response().withStatusCode(200));
+
+ HttpUrl url = HttpUrl.parse(this.baseUrl + "/test");
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .url(url)
+ .get()
+ .header("Accept", "text/plain")
+ .addHeader("X-Custom-Header", "custom-value1")
+ .addHeader("X-Custom-Header", "custom-value2")
+ .addHeader("X-Custom-Header", "custom-value3")
+ .build();
+
+ HttpResponse response = this.client.execute(request);
+
+ assertNotNull(response);
+ assertEquals(200, response.code());
+ assertTrue(response.isSuccessful());
+
+ this.server.verify(expectedRequest);
+ }
+
+ @Test
+ void testResponseHeaders() throws IOException {
+ org.mockserver.model.HttpRequest expectedRequest =
+ request().withMethod("GET").withPath("/test");
+ org.mockserver.model.HttpResponse resultResponse =
+ response()
+ .withStatusCode(200)
+ .withHeader("Content-Type", "text/plain")
+ .withHeader("X-Custom-Header", "value1", "value2", "value3")
+ .withBody("test-response");
+ this.server.when(expectedRequest).respond(resultResponse);
+
+ HttpUrl url = HttpUrl.parse(this.baseUrl + "/test");
+ HttpRequest request = HttpRequest.newBuilder().url(url).get().build();
+
+ HttpResponse response = this.client.execute(request);
+
+ assertNotNull(response);
+ assertEquals(200, response.code());
+ assertTrue(response.isSuccessful());
+ assertEquals("text/plain", response.header("Content-Type"));
+ assertEquals("value1", response.header("X-Custom-Header"));
+ List customHeaderValues = response.headers("X-Custom-Header");
+ assertEquals(3, customHeaderValues.size());
+ assertEquals("value1", customHeaderValues.get(0));
+ assertEquals("value2", customHeaderValues.get(1));
+ assertEquals("value3", customHeaderValues.get(2));
+
+ this.server.verify(expectedRequest);
+ }
+
+ @Test
+ void testNewBuilder() {
+ HttpClient.Builder builder = HttpClient.newBuilder();
+ assertNotNull(builder);
+
+ HttpClient client = builder.build();
+ assertNotNull(client);
+ }
+}
diff --git a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpRequestBodyTest.java b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpRequestBodyTest.java
new file mode 100644
index 00000000000..2faefcc3ce5
--- /dev/null
+++ b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpRequestBodyTest.java
@@ -0,0 +1,155 @@
+package datadog.http.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.GZIPInputStream;
+import org.junit.jupiter.api.Test;
+
+public class HttpRequestBodyTest {
+
+ // TODO Test empty string
+ // TODO Test empty byte array
+ // TODO Test empty ByteBuffer list
+
+ @Test
+ void testNullString() {
+ assertThrows(NullPointerException.class, () -> HttpRequestBody.of((String) null));
+ }
+
+ @Test
+ void testNullBytes() {
+ assertThrows(NullPointerException.class, () -> HttpRequestBody.of((byte[]) null));
+ }
+
+ @Test
+ void testNullByteBuffer() {
+ assertThrows(NullPointerException.class, () -> HttpRequestBody.of((List) null));
+ }
+
+ @Test
+ void testMultipartBuilder() {
+ HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart();
+ assertNotNull(builder);
+ }
+
+ @Test
+ void testMultipartContentType() {
+ HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart();
+ builder.addFormDataPart("name", "value");
+ String contentType = builder.contentType();
+ assertTrue(contentType.startsWith("multipart/form-data; boundary="));
+ }
+
+ @Test
+ void testMultipartAddFormDataPart() throws IOException {
+ HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart();
+ builder.addFormDataPart("name", "value");
+ HttpRequestBody body = builder.build();
+
+ assertNotNull(body);
+ assertTrue(body.contentLength() > 0);
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ body.writeTo(out);
+ String content = out.toString("UTF-8");
+ assertTrue(content.contains("name=\"name\""));
+ assertTrue(content.contains("value"));
+ }
+
+ @Test
+ void testMultipartAddFormDataPartWithFile() throws IOException {
+ HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart();
+ HttpRequestBody fileBody = HttpRequestBody.of("file content");
+ builder.addFormDataPart("file", "test.txt", fileBody);
+ HttpRequestBody body = builder.build();
+
+ assertNotNull(body);
+ assertTrue(body.contentLength() > 0);
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ body.writeTo(out);
+ String content = out.toString("UTF-8");
+ assertTrue(content.contains("name=\"file\""));
+ assertTrue(content.contains("filename=\"test.txt\""));
+ assertTrue(content.contains("file content"));
+ }
+
+ @Test
+ void testMultipartAddPart() throws IOException {
+ HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart();
+ Map headers = new HashMap<>();
+ headers.put("Content-Disposition", "form-data; name=\"custom\"; filename=\"data.bin\"");
+ HttpRequestBody partBody = HttpRequestBody.of("custom content");
+ builder.addPart(headers, partBody);
+ HttpRequestBody body = builder.build();
+
+ assertNotNull(body);
+ assertTrue(body.contentLength() > 0);
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ body.writeTo(out);
+ String content = out.toString("UTF-8");
+ assertTrue(content.contains("name=\"custom\""));
+ assertTrue(content.contains("custom content"));
+ }
+
+ @Test
+ void testMultipartNullParams() {
+ HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart();
+ assertThrows(NullPointerException.class, () -> builder.addFormDataPart(null, "value"));
+ assertThrows(NullPointerException.class, () -> builder.addFormDataPart("name", null));
+
+ HttpRequestBody fileBody = HttpRequestBody.of("content");
+ assertThrows(
+ NullPointerException.class, () -> builder.addFormDataPart(null, "file.txt", fileBody));
+ assertThrows(NullPointerException.class, () -> builder.addFormDataPart("name", null, fileBody));
+ assertThrows(
+ NullPointerException.class, () -> builder.addFormDataPart("name", "file.txt", null));
+
+ HttpRequestBody partBody = HttpRequestBody.of("content");
+ Map headers = new HashMap<>();
+ headers.put("Content-Disposition", "form-data; name=\"test\"");
+ assertThrows(NullPointerException.class, () -> builder.addPart(null, partBody));
+ assertThrows(NullPointerException.class, () -> builder.addPart(headers, null));
+ }
+
+ @Test
+ void testGzipBody() throws IOException {
+ String content = "this is test content for gzip compression";
+ HttpRequestBody originalBody = HttpRequestBody.of(content);
+ HttpRequestBody gzippedBody = HttpRequestBody.gzip(originalBody);
+ assertNotNull(gzippedBody);
+ // Content length is known since compression is done eagerly
+ assertTrue(gzippedBody.contentLength() > 0);
+ // Dump zipped content to bytes
+ ByteArrayOutputStream compressedOut = new ByteArrayOutputStream();
+ gzippedBody.writeTo(compressedOut);
+ byte[] compressedBytes = compressedOut.toByteArray();
+ // Decompress and verify content matches original
+ try (GZIPInputStream gzipIn = new GZIPInputStream(new ByteArrayInputStream(compressedBytes));
+ ByteArrayOutputStream decompressedOut = new ByteArrayOutputStream()) {
+ byte[] buffer = new byte[1024];
+ int len;
+ while ((len = gzipIn.read(buffer)) != -1) {
+ decompressedOut.write(buffer, 0, len);
+ }
+ String decompressedContent = decompressedOut.toString("UTF-8");
+ assertEquals(content, decompressedContent);
+ }
+ }
+
+ @Test
+ void testGzipNullBody() {
+ assertThrows(NullPointerException.class, () -> HttpRequestBody.gzip(null));
+ }
+}
diff --git a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpRequestTest.java b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpRequestTest.java
new file mode 100644
index 00000000000..28b9efb0484
--- /dev/null
+++ b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpRequestTest.java
@@ -0,0 +1,142 @@
+package datadog.http.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+public class HttpRequestTest {
+ @Test
+ void testGetRequest() {
+ HttpUrl url = HttpUrl.parse("http://localhost:8080/api");
+ HttpRequest request = HttpRequest.newBuilder().url(url).get().build();
+
+ assertNotNull(request);
+ assertEquals(url, request.url());
+ assertEquals("GET", request.method());
+ }
+
+ @Test
+ void testPostRequest() {
+ HttpUrl url = HttpUrl.parse("http://localhost:8080/api");
+ String payload = "{\"key\":\"value\"}";
+ HttpRequestBody body = HttpRequestBody.of(payload);
+
+ HttpRequest request = HttpRequest.newBuilder().url(url).post(body).build();
+
+ assertNotNull(request);
+ assertEquals(url, request.url());
+ assertEquals("POST", request.method());
+ }
+
+ @Test
+ void testPutRequest() {
+ HttpUrl url = HttpUrl.parse("http://localhost:8080/api");
+ String payload = "{\"key\":\"value\"}";
+ HttpRequestBody body = HttpRequestBody.of(payload);
+
+ HttpRequest request = HttpRequest.newBuilder().url(url).put(body).build();
+
+ assertEquals("PUT", request.method());
+ }
+
+ @Test
+ void testWithoutMethod() {
+ HttpRequest request = HttpRequest.newBuilder().url("http://localhost:8080/test").build();
+ assertEquals("GET", request.method());
+ }
+
+ @Test
+ void testRequestWithUrlString() {
+ HttpRequest request = HttpRequest.newBuilder().url("http://localhost:8080/test").get().build();
+
+ assertNotNull(request);
+ assertEquals("http://localhost:8080/test", request.url().url());
+ }
+
+ @Test
+ void testRequestWithSingleHeader() {
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .url("http://localhost:8080/test")
+ .header("Content-Type", "application/json")
+ .get()
+ .build();
+
+ assertEquals("application/json", request.header("Content-Type"));
+ }
+
+ @Test
+ void testRequestWithMultipleHeaders() {
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .url("http://localhost:8080/test")
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .addHeader("X-Custom-Header", "value1")
+ .addHeader("X-Custom-Header", "value2")
+ .get()
+ .build();
+
+ assertEquals("application/json", request.header("Content-Type"));
+ assertEquals("application/json", request.header("Accept"));
+
+ List customHeaders = request.headers("X-Custom-Header");
+ assertEquals(2, customHeaders.size());
+ assertTrue(customHeaders.contains("value1"));
+ assertTrue(customHeaders.contains("value2"));
+ }
+
+ @Test
+ void testWithoutUrl() {
+ assertThrows(IllegalStateException.class, () -> HttpRequest.newBuilder().get().build());
+ }
+
+ @Test
+ void testHeaderReplacement() {
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .url("http://localhost:8080/test")
+ .header("Content-Type", "text/plain")
+ .header("Content-Type", "application/json")
+ .get()
+ .build();
+
+ assertEquals("application/json", request.header("Content-Type"));
+ }
+
+ @Test
+ void testMissingHeader() {
+ HttpRequest request = HttpRequest.newBuilder().url("http://localhost:8080/test").get().build();
+
+ assertNull(request.header("X-Missing"));
+ List missing = request.headers("X-Missing");
+ assertNotNull(missing);
+ assertTrue(missing.isEmpty());
+ }
+
+ @Test
+ void testEmptyHeaderValue() {
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .url("http://localhost:8080/test")
+ .header("X-Empty-Header", "")
+ .get()
+ .build();
+
+ assertEquals("", request.header("X-Empty-Header"));
+ }
+
+ @Test
+ void testNullHeader() {
+ HttpRequest.Builder builder = HttpRequest.newBuilder().url("http://localhost:8080/test");
+ assertThrows(NullPointerException.class, () -> builder.header(null, "value"));
+ assertThrows(NullPointerException.class, () -> builder.header("X-Custom-Header", null));
+ assertThrows(NullPointerException.class, () -> builder.addHeader(null, "value"));
+ assertThrows(NullPointerException.class, () -> builder.addHeader("X-Custom-Header", null));
+ }
+}
diff --git a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpResponseTest.java b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpResponseTest.java
new file mode 100644
index 00000000000..2bfde756dcc
--- /dev/null
+++ b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpResponseTest.java
@@ -0,0 +1,136 @@
+package datadog.http.client;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.Set;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockserver.integration.ClientAndServer;
+import org.mockserver.junit.jupiter.MockServerExtension;
+
+@ExtendWith(MockServerExtension.class)
+public class HttpResponseTest {
+ private ClientAndServer server;
+ private HttpClient client;
+ private String baseUrl;
+
+ @BeforeEach
+ void setUp(ClientAndServer server) {
+ this.server = server;
+ this.client = HttpClient.newBuilder().build();
+ this.baseUrl = "http://localhost:" + server.getPort();
+ }
+
+ @AfterEach
+ void tearDown() {
+ this.server.reset();
+ }
+
+ @Test
+ void testBody() throws IOException {
+ org.mockserver.model.HttpRequest expectedRequest =
+ request().withMethod("GET").withPath("/test");
+ String responseBody = "content";
+ this.server.when(expectedRequest).respond(response().withBody(responseBody));
+
+ HttpUrl url = HttpUrl.parse(this.baseUrl + "/test");
+ HttpRequest request = HttpRequest.newBuilder().url(url).get().build();
+
+ HttpResponse response = this.client.execute(request);
+
+ assertNotNull(response);
+ assertEquals(200, response.code());
+ assertTrue(response.isSuccessful());
+ try (InputStream body = response.body()) {
+ assertEquals(responseBody, readAll(body));
+ }
+ }
+
+ @Test
+ void testEmptyBody() throws IOException {
+ org.mockserver.model.HttpRequest expectedRequest =
+ request().withMethod("GET").withPath("/test");
+ this.server.when(expectedRequest).respond(response());
+
+ HttpUrl url = HttpUrl.parse(this.baseUrl + "/test");
+ HttpRequest request = HttpRequest.newBuilder().url(url).get().build();
+
+ HttpResponse response = this.client.execute(request);
+
+ assertNotNull(response);
+ assertEquals(200, response.code());
+ assertTrue(response.isSuccessful());
+ try (InputStream body = response.body()) {
+ assertEquals("", readAll(body));
+ }
+ }
+
+ @Test
+ void testHeader() throws IOException {
+ org.mockserver.model.HttpRequest expectedRequest =
+ request().withMethod("GET").withPath("/test");
+ org.mockserver.model.HttpResponse resultResponse =
+ response().withHeader("Content-Type", "text/plain");
+ this.server.when(expectedRequest).respond(resultResponse);
+
+ HttpUrl url = HttpUrl.parse(this.baseUrl + "/test");
+ HttpRequest request = HttpRequest.newBuilder().url(url).get().build();
+
+ HttpResponse response = this.client.execute(request);
+
+ // case-insensitive
+ assertEquals("text/plain", response.header("Content-Type"));
+ assertEquals("text/plain", response.header("content-type"));
+ assertEquals("text/plain", response.header("CONTENT-TYPE"));
+ // missing header
+ assertNull(response.header("X-Missing-Header"));
+ assertTrue(response.headers("X-Missing-Header").isEmpty());
+ }
+
+ @Test
+ void testHeaderNames() throws IOException {
+ org.mockserver.model.HttpRequest expectedRequest =
+ request().withMethod("GET").withPath("/test");
+ org.mockserver.model.HttpResponse resultResponse =
+ response()
+ .withHeader("Content-Type", "application/json")
+ .withHeader("X-Custom-Header", "custom-value")
+ .withHeader("X-Another-Header", "another-value");
+ this.server.when(expectedRequest).respond(resultResponse);
+
+ HttpUrl url = HttpUrl.parse(this.baseUrl + "/test");
+ HttpRequest request = HttpRequest.newBuilder().url(url).get().build();
+
+ HttpResponse response = this.client.execute(request);
+
+ Set headerNames = response.headerNames();
+ assertTrue(headerNames.contains("Content-Type"));
+ assertTrue(headerNames.contains("X-Custom-Header"));
+ assertTrue(headerNames.contains("X-Another-Header"));
+ }
+
+ private String readAll(InputStream in) throws IOException {
+ BufferedReader reader = new BufferedReader(new InputStreamReader(in, UTF_8));
+ StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (sb.length() > 0) {
+ sb.append('\n');
+ }
+ sb.append(line);
+ }
+ return sb.toString();
+ }
+}
diff --git a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpUrlTest.java b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpUrlTest.java
new file mode 100644
index 00000000000..da4f6f1b513
--- /dev/null
+++ b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpUrlTest.java
@@ -0,0 +1,425 @@
+package datadog.http.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URI;
+import org.junit.jupiter.api.Test;
+
+// Mostly a bunch of generated tests to ensure similar behavior of the different implementations
+public class HttpUrlTest {
+
+ // ==================== parse() tests ====================
+
+ @Test
+ void testParseSimpleUrl() {
+ HttpUrl url = HttpUrl.parse("https://example.com");
+
+ assertNotNull(url);
+ assertEquals("https", url.scheme());
+ assertEquals("example.com", url.host());
+ assertEquals(443, url.port());
+ }
+
+ @Test
+ void testParseUrlWithPort() {
+ HttpUrl url = HttpUrl.parse("http://localhost:8080");
+
+ assertEquals("http", url.scheme());
+ assertEquals("localhost", url.host());
+ assertEquals(8080, url.port());
+ }
+
+ @Test
+ void testParseUrlWithPath() {
+ HttpUrl url = HttpUrl.parse("https://example.com/api/v1/users");
+
+ assertEquals("https", url.scheme());
+ assertEquals("example.com", url.host());
+ assertTrue(url.url().contains("/api/v1/users"));
+ }
+
+ @Test
+ void testParseUrlWithQueryParameters() {
+ HttpUrl url = HttpUrl.parse("https://example.com/search?q=test&page=1");
+
+ assertTrue(url.url().contains("q=test"));
+ assertTrue(url.url().contains("page=1"));
+ }
+
+ @Test
+ void testParseInvalidUrl() {
+ assertThrows(IllegalArgumentException.class, () -> HttpUrl.parse("not a valid url"));
+ }
+
+ @Test
+ void testParseNullUrl() {
+ assertThrows(NullPointerException.class, () -> HttpUrl.parse(null));
+ }
+
+ // ==================== from(URI) tests ====================
+
+ @Test
+ void testFromUri() {
+ URI uri = URI.create("https://example.com:8443/path");
+ HttpUrl url = HttpUrl.from(uri);
+
+ assertNotNull(url);
+ assertEquals("https", url.scheme());
+ assertEquals("example.com", url.host());
+ assertEquals(8443, url.port());
+ assertTrue(url.url().contains("/path"));
+ }
+
+ @Test
+ void testFromUriNull() {
+ assertThrows(NullPointerException.class, () -> HttpUrl.from(null));
+ }
+
+ // ==================== scheme() tests ====================
+
+ @Test
+ void testSchemeHttp() {
+ HttpUrl url = HttpUrl.parse("http://example.com");
+ assertEquals("http", url.scheme());
+ }
+
+ @Test
+ void testSchemeHttps() {
+ HttpUrl url = HttpUrl.parse("https://example.com");
+ assertEquals("https", url.scheme());
+ }
+
+ // ==================== host() tests ====================
+
+ @Test
+ void testHostDomain() {
+ HttpUrl url = HttpUrl.parse("https://www.example.com");
+ assertEquals("www.example.com", url.host());
+ }
+
+ @Test
+ void testHostLocalhost() {
+ HttpUrl url = HttpUrl.parse("http://localhost:8080");
+ assertEquals("localhost", url.host());
+ }
+
+ @Test
+ void testHostIpAddress() {
+ HttpUrl url = HttpUrl.parse("http://192.168.1.1:8080");
+ assertEquals("192.168.1.1", url.host());
+ }
+
+ // ==================== port() tests ====================
+
+ @Test
+ void testPortExplicit() {
+ HttpUrl url = HttpUrl.parse("https://example.com:8443");
+ assertEquals(8443, url.port());
+ }
+
+ @Test
+ void testPortDefaultHttp() {
+ HttpUrl url = HttpUrl.parse("http://example.com");
+ assertEquals(80, url.port());
+ }
+
+ @Test
+ void testPortDefaultHttps() {
+ HttpUrl url = HttpUrl.parse("https://example.com");
+ assertEquals(443, url.port());
+ }
+
+ // ==================== resolve() tests ====================
+
+ @Test
+ void testResolveRelativePath() {
+ HttpUrl baseUrl = HttpUrl.parse("https://example.com/api");
+ HttpUrl resolved = baseUrl.resolve("users");
+
+ assertTrue(resolved.url().contains("example.com"));
+ assertTrue(resolved.url().contains("users"));
+ }
+
+ @Test
+ void testResolveAbsolutePath() {
+ HttpUrl baseUrl = HttpUrl.parse("https://example.com/api/v1");
+ HttpUrl resolved = baseUrl.resolve("/v2/users");
+
+ assertTrue(resolved.url().contains("example.com"));
+ assertTrue(resolved.url().contains("/v2/users"));
+ assertFalse(resolved.url().contains("/api"));
+ }
+
+ @Test
+ void testResolveWithQueryParameters() {
+ HttpUrl baseUrl = HttpUrl.parse("https://example.com/api");
+ HttpUrl resolved = baseUrl.resolve("search?q=test");
+
+ assertTrue(resolved.url().contains("search"));
+ assertTrue(resolved.url().contains("q=test"));
+ }
+
+ // ==================== newBuilder() tests ====================
+
+ @Test
+ void testNewBuilderPreservesUrl() {
+ HttpUrl original = HttpUrl.parse("https://example.com:8443/api");
+ HttpUrl rebuilt = original.newBuilder().build();
+
+ assertEquals(original.scheme(), rebuilt.scheme());
+ assertEquals(original.host(), rebuilt.host());
+ assertEquals(original.port(), rebuilt.port());
+ }
+
+ @Test
+ void testNewBuilderAllowsModification() {
+ HttpUrl original = HttpUrl.parse("https://example.com/api");
+ HttpUrl modified = original.newBuilder().addPathSegment("v2").build();
+
+ assertTrue(modified.url().contains("/api"));
+ assertTrue(modified.url().contains("v2"));
+ }
+
+ // ==================== addPathSegment() tests ====================
+
+ @Test
+ void testAddPathSegmentSingle() {
+ HttpUrl url =
+ HttpUrl.builder().scheme("https").host("example.com").addPathSegment("api").build();
+
+ assertTrue(url.url().contains("/api"));
+ }
+
+ @Test
+ void testAddPathSegmentMultiple() {
+ HttpUrl url =
+ HttpUrl.builder()
+ .scheme("https")
+ .host("example.com")
+ .addPathSegment("api")
+ .addPathSegment("v1")
+ .addPathSegment("users")
+ .build();
+
+ String urlString = url.url();
+ assertTrue(urlString.contains("/api"));
+ assertTrue(urlString.contains("/v1"));
+ assertTrue(urlString.contains("/users"));
+ }
+
+ @Test
+ void testAddPathSegmentWithPort() {
+ HttpUrl url =
+ HttpUrl.builder()
+ .scheme("https")
+ .host("example.com")
+ .port(8443)
+ .addPathSegment("api")
+ .build();
+
+ String urlString = url.url();
+ assertTrue(urlString.contains(":8443"));
+ assertTrue(urlString.contains("/api"));
+ }
+
+ // ==================== builder scheme/host/port tests ====================
+
+ @Test
+ void testBuilderSchemeHostPort() {
+ HttpUrl url = HttpUrl.builder().scheme("https").host("api.example.com").port(8443).build();
+
+ assertEquals("https", url.scheme());
+ assertEquals("api.example.com", url.host());
+ assertEquals(8443, url.port());
+ }
+
+ @Test
+ void testBuilderDefaultScheme() {
+ HttpUrl url = HttpUrl.builder().host("example.com").build();
+
+ assertEquals("http", url.scheme());
+ }
+
+ // ==================== addQueryParameter() tests ====================
+
+ @Test
+ void testAddQueryParameterSingle() {
+ HttpUrl url =
+ HttpUrl.builder()
+ .scheme("https")
+ .host("example.com")
+ .addQueryParameter("key", "value")
+ .build();
+
+ String urlString = url.url();
+ // OkHttp adds trailing slash, JDK doesn't - both are valid
+ assertTrue(
+ urlString.matches("https://example\\.com/?\\?key=value"),
+ "Expected URL with query parameter, got: " + urlString);
+ }
+
+ @Test
+ void testAddQueryParameterMultiple() {
+ HttpUrl url =
+ HttpUrl.builder()
+ .scheme("https")
+ .host("example.com")
+ .addQueryParameter("key1", "value1")
+ .addQueryParameter("key2", "value2")
+ .addQueryParameter("key3", "value3")
+ .build();
+
+ String urlString = url.url();
+ // OkHttp adds trailing slash, JDK doesn't - both are valid
+ assertTrue(
+ urlString.matches("https://example\\.com/?\\?.*"),
+ "Expected URL with query parameters, got: " + urlString);
+ assertTrue(urlString.contains("key1=value1"));
+ assertTrue(urlString.contains("key2=value2"));
+ assertTrue(urlString.contains("key3=value3"));
+ assertTrue(urlString.contains("&"));
+ }
+
+ @Test
+ void testAddQueryParameterWithNullValue() {
+ HttpUrl url =
+ HttpUrl.builder()
+ .scheme("https")
+ .host("example.com")
+ .addQueryParameter("flag", null)
+ .build();
+
+ String urlString = url.url();
+ assertTrue(urlString.contains("flag"));
+ assertFalse(urlString.contains("="));
+ assertFalse(urlString.contains("null"));
+ }
+
+ @Test
+ void testAddQueryParameterWithEncoding() {
+ HttpUrl url =
+ HttpUrl.builder()
+ .scheme("https")
+ .host("example.com")
+ .addQueryParameter("message", "hello world")
+ .addQueryParameter("special", "a=b&c=d")
+ .build();
+
+ String urlString = url.url();
+ // Values should be URL encoded - accept both + and %20 for space
+ assertTrue(
+ urlString.contains("message=hello+world") || urlString.contains("message=hello%20world"),
+ "Expected encoded space in URL, got: " + urlString);
+ assertTrue(
+ urlString.contains("special=a%3Db%26c%3Dd"),
+ "Expected encoded special chars in URL, got: " + urlString);
+ }
+
+ @Test
+ void testAddQueryParameterWithPath() {
+ HttpUrl url =
+ HttpUrl.builder()
+ .scheme("https")
+ .host("example.com")
+ .addPathSegment("api")
+ .addPathSegment("v1")
+ .addQueryParameter("page", "1")
+ .addQueryParameter("limit", "10")
+ .build();
+
+ String urlString = url.url();
+ assertTrue(urlString.contains("example.com/api/v1"));
+ assertTrue(urlString.contains("page=1"));
+ assertTrue(urlString.contains("limit=10"));
+ }
+
+ @Test
+ void testAddQueryParameterFromExistingUrl() {
+ HttpUrl baseUrl = HttpUrl.parse("https://example.com/api");
+ HttpUrl url = baseUrl.newBuilder().addQueryParameter("token", "abc123").build();
+
+ String urlString = url.url();
+ assertTrue(urlString.contains("example.com/api"));
+ assertTrue(urlString.contains("token=abc123"));
+ }
+
+ @Test
+ void testAddQueryParameterPreservesExistingQuery() {
+ HttpUrl baseUrl = HttpUrl.parse("https://example.com/api?existing=param");
+ HttpUrl url = baseUrl.newBuilder().addQueryParameter("new", "value").build();
+
+ String urlString = url.url();
+ assertTrue(urlString.contains("existing=param"));
+ assertTrue(urlString.contains("new=value"));
+ }
+
+ @Test
+ void testAddQueryParameterEmptyValue() {
+ HttpUrl url =
+ HttpUrl.builder().scheme("https").host("example.com").addQueryParameter("key", "").build();
+
+ String urlString = url.url();
+ assertTrue(urlString.contains("key="));
+ }
+
+ @Test
+ void testAddQueryParameterSpecialCharactersInName() {
+ HttpUrl url =
+ HttpUrl.builder()
+ .scheme("https")
+ .host("example.com")
+ .addQueryParameter("my-key", "value")
+ .addQueryParameter("my_key", "value2")
+ .build();
+
+ String urlString = url.url();
+ assertTrue(urlString.contains("my-key=value"));
+ assertTrue(urlString.contains("my_key=value2"));
+ }
+
+ @Test
+ void testAddQueryParameterWithPort() {
+ HttpUrl url =
+ HttpUrl.builder()
+ .scheme("https")
+ .host("example.com")
+ .port(8443)
+ .addQueryParameter("key", "value")
+ .build();
+
+ String urlString = url.url();
+ assertTrue(urlString.contains(":8443"));
+ assertTrue(urlString.contains("key=value"));
+ }
+
+ // ==================== url() tests ====================
+
+ @Test
+ void testUrlReturnsCompleteUrl() {
+ HttpUrl url =
+ HttpUrl.builder()
+ .scheme("https")
+ .host("example.com")
+ .port(8443)
+ .addPathSegment("api")
+ .addQueryParameter("key", "value")
+ .build();
+
+ String urlString = url.url();
+ assertTrue(urlString.startsWith("https://"));
+ assertTrue(urlString.contains("example.com"));
+ assertTrue(urlString.contains(":8443"));
+ assertTrue(urlString.contains("/api"));
+ assertTrue(urlString.contains("key=value"));
+ }
+
+ @Test
+ void testUrlMatchesToString() {
+ HttpUrl url = HttpUrl.parse("https://example.com/api");
+ assertEquals(url.url(), url.toString());
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 3053ab723f8..298c387457c 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -105,6 +105,7 @@ include(
":communication",
":components:context",
":components:environment",
+ ":components:http:http-api",
":components:json",
":components:native-loader",
":products:metrics:metrics-agent",