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",