diff --git a/dapr-spring/dapr-spring-boot-observation/src/main/java/io/dapr/spring/observation/client/ObservationDaprClient.java b/dapr-spring/dapr-spring-boot-observation/src/main/java/io/dapr/spring/observation/client/ObservationDaprClient.java index 575a1887fc..5fe10a3a05 100644 --- a/dapr-spring/dapr-spring-boot-observation/src/main/java/io/dapr/spring/observation/client/ObservationDaprClient.java +++ b/dapr-spring/dapr-spring-boot-observation/src/main/java/io/dapr/spring/observation/client/ObservationDaprClient.java @@ -14,6 +14,7 @@ package io.dapr.spring.observation.client; import io.dapr.client.DaprClient; +import io.dapr.client.DaprInvokeHttpClient; import io.dapr.client.domain.BulkPublishRequest; import io.dapr.client.domain.BulkPublishResponse; import io.dapr.client.domain.ConfigurationItem; @@ -334,6 +335,11 @@ public Mono invokeMethod(InvokeMethodRequest invokeMethodRequest, TypeRef return delegate.invokeMethod(invokeMethodRequest, type); } + @Override + public DaprInvokeHttpClient invokeHttpClient(String appId) { + return delegate.invokeHttpClient(appId); + } + // ------------------------------------------------------------------------- // Bindings // ------------------------------------------------------------------------- diff --git a/dapr-spring/dapr-spring-boot-observation/src/test/java/io/dapr/spring/observation/client/ObservationDaprClientTest.java b/dapr-spring/dapr-spring-boot-observation/src/test/java/io/dapr/spring/observation/client/ObservationDaprClientTest.java index e37a332df2..b711f5026c 100644 --- a/dapr-spring/dapr-spring-boot-observation/src/test/java/io/dapr/spring/observation/client/ObservationDaprClientTest.java +++ b/dapr-spring/dapr-spring-boot-observation/src/test/java/io/dapr/spring/observation/client/ObservationDaprClientTest.java @@ -14,13 +14,10 @@ package io.dapr.spring.observation.client; import io.dapr.client.DaprClient; -import io.dapr.client.domain.DeleteStateRequest; -import io.dapr.client.domain.GetSecretRequest; -import io.dapr.client.domain.GetStateRequest; +import io.dapr.client.DaprInvokeHttpClient; import io.dapr.client.domain.InvokeBindingRequest; import io.dapr.client.domain.PublishEventRequest; import io.dapr.client.domain.ScheduleJobRequest; -import io.dapr.client.domain.State; import io.micrometer.observation.tck.TestObservationRegistry; import io.micrometer.observation.tck.TestObservationRegistryAssert; import org.junit.jupiter.api.BeforeEach; @@ -33,7 +30,6 @@ import reactor.core.publisher.Mono; import java.util.List; -import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -378,4 +374,18 @@ void deprecatedInvokeMethodDoesNotCreateSpan() { // Registry must be empty — no spans for deprecated methods TestObservationRegistryAssert.assertThat(registry).doesNotHaveAnyObservation(); } + + @Test + @DisplayName("invokeHttpClient delegates without creating a span") + void invokeHttpClientDelegatesWithoutSpan() { + DaprInvokeHttpClient stub = org.mockito.Mockito.mock(DaprInvokeHttpClient.class); + when(delegate.invokeHttpClient("orderprocessor")).thenReturn(stub); + + DaprInvokeHttpClient result = client.invokeHttpClient("orderprocessor"); + + assertThat(result).isSameAs(stub); + verify(delegate).invokeHttpClient("orderprocessor"); + // Synchronous factory — no Mono/Flux to observe, so no span is expected. + TestObservationRegistryAssert.assertThat(registry).doesNotHaveAnyObservation(); + } } diff --git a/examples/src/main/java/io/dapr/examples/invoke/http/InvokeClient.java b/examples/src/main/java/io/dapr/examples/invoke/http/InvokeClient.java index 3f00cb680c..b97d24312c 100644 --- a/examples/src/main/java/io/dapr/examples/invoke/http/InvokeClient.java +++ b/examples/src/main/java/io/dapr/examples/invoke/http/InvokeClient.java @@ -15,7 +15,13 @@ import io.dapr.client.DaprClient; import io.dapr.client.DaprClientBuilder; -import io.dapr.client.domain.HttpExtension; +import io.dapr.client.DaprInvokeHttpClient; +import io.dapr.config.Properties; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; /** * 1. Build and install jars: @@ -24,6 +30,16 @@ * 3. Send messages to the server: * dapr run -- java -jar target/dapr-java-sdk-examples-exec.jar \ * io.dapr.examples.invoke.http.InvokeClient 'message one' 'message two' + * + *

This example demonstrates calling another Dapr-enabled application over HTTP. + * Two equivalent approaches are shown: + *

    + *
  1. {@link DaprClient#invokeHttpClient(String)} — an SDK-provided {@link java.net.http.HttpClient} + * wrapper pre-bound to the sidecar's {@code /v1.0/invoke/<app-id>/method/} prefix, + * with the {@code dapr-api-token} header attached when configured.
  2. + *
  3. A raw {@link java.net.http.HttpClient} sending the request to the sidecar's base URL + * with a {@code dapr-app-id} header identifying the target app — no SDK helper required.
  4. + *
*/ public class InvokeClient { @@ -32,22 +48,49 @@ public class InvokeClient { */ private static final String SERVICE_APP_ID = "invokedemo"; + /** + * Method on the target service to invoke. + */ + private static final String METHOD = "say"; + /** * Starts the invoke client. * * @param args Messages to be sent as request for the invoke API. */ public static void main(String[] args) throws Exception { - try (DaprClient client = (new DaprClientBuilder()).build()) { + try (DaprClient daprClient = new DaprClientBuilder().build()) { + DaprInvokeHttpClient invoker = daprClient.invokeHttpClient(SERVICE_APP_ID); + + int port = Properties.HTTP_PORT.get(); + String sidecarBase = "http://localhost:" + port; + HttpClient rawHttpClient = HttpClient.newHttpClient(); + for (String message : args) { - byte[] response = client.invokeMethod(SERVICE_APP_ID, "say", message, HttpExtension.POST, null, - byte[].class).block(); - System.out.println(new String(response)); - } + // Form 1: SDK helper — paths resolve against /v1.0/invoke//method/. + HttpRequest sdkRequest = invoker.newRequestBuilder(METHOD) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(message)) + .build(); + HttpResponse sdkResponse = + invoker.send(sdkRequest, HttpResponse.BodyHandlers.ofByteArray()); + System.out.println(new String(sdkResponse.body())); - // This is an example, so for simplicity we are just exiting here. - // Normally a dapr app would be a web service and not exit main. - System.out.println("Done"); + // Form 2: raw HttpClient + dapr-app-id header against the sidecar's base URL. + HttpRequest headerRequest = HttpRequest.newBuilder() + .uri(URI.create(sidecarBase + "/" + METHOD)) + .header("Content-Type", "application/json") + .header("dapr-app-id", SERVICE_APP_ID) + .POST(HttpRequest.BodyPublishers.ofString(message)) + .build(); + HttpResponse headerResponse = + rawHttpClient.send(headerRequest, HttpResponse.BodyHandlers.ofByteArray()); + System.out.println(new String(headerResponse.body())); + } } + + // This is an example, so for simplicity we are just exiting here. + // Normally a dapr app would be a web service and not exit main. + System.out.println("Done"); } } diff --git a/examples/src/main/java/io/dapr/examples/invoke/http/README.md b/examples/src/main/java/io/dapr/examples/invoke/http/README.md index 5636656ca6..dd99ae59e7 100644 --- a/examples/src/main/java/io/dapr/examples/invoke/http/README.md +++ b/examples/src/main/java/io/dapr/examples/invoke/http/README.md @@ -8,9 +8,14 @@ This sample includes: Visit [this](https://docs.dapr.io/developing-applications/building-blocks/service-invocation/service-invocation-overview/) link for more information about Dapr and service invocation. -## Remote invocation using the Java-SDK +## Remote invocation using a native HTTP client -This sample uses the Client provided in Dapr Java SDK invoking a remote method. +This sample invokes a method on another Dapr-enabled application via the Dapr sidecar using `java.net.http.HttpClient`. The previous SDK-provided `DaprClient.invokeMethod` wrappers are deprecated; calling the sidecar directly is the recommended approach. + +Two equivalent approaches are demonstrated: + +1. `DaprClient.invokeHttpClient(appId)` — an SDK-provided wrapper that returns a pre-configured `HttpClient` bound to the sidecar's `/v1.0/invoke//method/` prefix, with the `dapr-api-token` header attached when configured. +2. A raw `java.net.http.HttpClient` sending the request to the sidecar's base URL with a `dapr-app-id` header identifying the target app — no SDK helper required. ## Pre-requisites @@ -121,39 +126,62 @@ Once running, the ExposerService is now ready to be invoked by Dapr. ### Running the InvokeClient sample -The Invoke client sample uses the Dapr SDK for invoking the remote method. The main method declares a Dapr Client using the `DaprClientBuilder` class. Notice that [DaprClientBuilder](https://github.com/dapr/java-sdk/blob/master/sdk/src/main/java/io/dapr/client/DaprClientBuilder.java) can receive two optional serializers: `withObjectSerializer()` is for Dapr's sent and received objects, and `withStateSerializer()` is for objects to be persisted. It needs to know the method name to invoke as well as the application id for the remote application. This example, we stick to the [default serializer](https://github.com/dapr/java-sdk/blob/master/sdk/src/main/java/io/dapr/serializer/DefaultObjectSerializer.java). In `InvokeClient.java` file, you will find the `InvokeClient` class and the `main` method. See the code snippet below: +The Invoke client sample calls the remote method through the Dapr sidecar using two equivalent approaches: + +1. `DaprClient.invokeHttpClient(appId)` — an SDK-provided wrapper around `java.net.http.HttpClient` pre-bound to `/v1.0/invoke//method/`. +2. A raw `java.net.http.HttpClient` against the sidecar's base URL with a `dapr-app-id` header. + +In `InvokeClient.java` file, you will find the `InvokeClient` class and the `main` method. See the code snippet below: ```java public class InvokeClient { -private static final String SERVICE_APP_ID = "invokedemo"; -///... -public static void main(String[] args) throws Exception { - try (DaprClient client = (new DaprClientBuilder()).build()) { + private static final String SERVICE_APP_ID = "invokedemo"; + private static final String METHOD = "say"; + + public static void main(String[] args) throws Exception { + try (DaprClient daprClient = new DaprClientBuilder().build()) { + DaprInvokeHttpClient invoker = daprClient.invokeHttpClient(SERVICE_APP_ID); + + int port = Properties.HTTP_PORT.get(); + String sidecarBase = "http://localhost:" + port; + HttpClient rawHttpClient = HttpClient.newHttpClient(); + for (String message : args) { - byte[] response = client.invokeMethod(SERVICE_APP_ID, "say", message, HttpExtension.POST, null, - byte[].class).block(); - System.out.println(new String(response)); + // Form 1: SDK helper — paths resolve against /v1.0/invoke//method/. + HttpRequest sdkRequest = invoker.newRequestBuilder(METHOD) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(message)) + .build(); + HttpResponse sdkResponse = + invoker.send(sdkRequest, HttpResponse.BodyHandlers.ofByteArray()); + System.out.println(new String(sdkResponse.body())); + + // Form 2: raw HttpClient + dapr-app-id header against the sidecar's base URL. + HttpRequest headerRequest = HttpRequest.newBuilder() + .uri(URI.create(sidecarBase + "/" + METHOD)) + .header("Content-Type", "application/json") + .header("dapr-app-id", SERVICE_APP_ID) + .POST(HttpRequest.BodyPublishers.ofString(message)) + .build(); + HttpResponse headerResponse = + rawHttpClient.send(headerRequest, HttpResponse.BodyHandlers.ofByteArray()); + System.out.println(new String(headerResponse.body())); } - - // This is an example, so for simplicity we are just exiting here. - // Normally a dapr app would be a web service and not exit main. - System.out.println("Done"); } + + System.out.println("Done"); } -///... } ``` -The class knows the app id for the remote application. It uses the the static `Dapr.getInstance().invokeMethod` method to invoke the remote method defining the parameters: The verb, application id, method name, and proper data and metadata, as well as the type of the expected return type. The returned payload for this method invocation is plain text and not a [JSON String](https://www.w3schools.com/js/js_json_datatypes.asp), so we expect `byte[]` to get the raw response and not try to deserialize it. - +Form 1 uses `DaprClient.invokeHttpClient(SERVICE_APP_ID)` to obtain an HTTP client whose base URI already targets the desired app via the sidecar's invoke API. Form 2 sends the request directly to the sidecar's base URL and uses the `dapr-app-id` header to identify the target app. Both forms call the remote `say` method and print its response. + Execute the follow script in order to run the InvokeClient example, passing two messages for the remote method: -Finally, the console for `invokeclient` should output: +Finally, the console for `invokeclient` should output two timestamps per message — one from each URL form — followed by `Done`. The exact timestamps come from the `say` method on `DemoService`. For example: ```text -"message one" received - -"message two" received - +2026-05-12 13:45:00.123 +2026-05-12 13:45:00.456 +2026-05-12 13:45:00.789 +2026-05-12 13:45:01.012 Done - ``` For more details on Dapr Spring Boot integration, please refer to [Dapr Spring Boot](../../DaprApplication.java) Application implementation. diff --git a/sdk/src/main/java/io/dapr/client/DaprClient.java b/sdk/src/main/java/io/dapr/client/DaprClient.java index 00acb18971..d57780ed23 100644 --- a/sdk/src/main/java/io/dapr/client/DaprClient.java +++ b/sdk/src/main/java/io/dapr/client/DaprClient.java @@ -353,6 +353,19 @@ Mono invokeMethod(String appId, String methodName, byte[] request, HttpE @Deprecated Mono invokeMethod(InvokeMethodRequest invokeMethodRequest, TypeRef type); + /** + * Creates an HTTP client pre-configured for Dapr service invocation against the given app id. + * + *

The returned client resolves relative paths against + * {@code {daprHttpEndpoint}/v1.0/invoke/{appId}/method/} and automatically attaches the + * {@code dapr-api-token} header when one is configured. It reuses the SDK's shared + * {@link java.net.http.HttpClient} instance. + * + * @param appId the application id to invoke. + * @return a {@link DaprInvokeHttpClient} bound to {@code appId}. + */ + DaprInvokeHttpClient invokeHttpClient(String appId); + /** * Invokes a Binding operation. * diff --git a/sdk/src/main/java/io/dapr/client/DaprClientImpl.java b/sdk/src/main/java/io/dapr/client/DaprClientImpl.java index e5c95ec0d7..71842cee6c 100644 --- a/sdk/src/main/java/io/dapr/client/DaprClientImpl.java +++ b/sdk/src/main/java/io/dapr/client/DaprClientImpl.java @@ -130,6 +130,7 @@ import javax.annotation.Nonnull; import java.io.IOException; +import java.net.URI; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; @@ -656,6 +657,20 @@ public Mono invokeMethod(InvokeMethodRequest invokeMethodRequest, TypeRef } } + @Override + public DaprInvokeHttpClient invokeHttpClient(String appId) { + if (appId == null || appId.trim().isEmpty()) { + throw new IllegalArgumentException("App Id cannot be null or empty."); + } + URI invokeBase = this.httpClient.getBaseUri() + .resolve("/" + DaprHttp.API_VERSION + "/invoke/" + appId + "/method/"); + return new DaprInvokeHttpClient( + this.httpClient.getHttpClient(), + invokeBase, + this.httpClient.getDaprApiToken(), + this.httpClient.getReadTimeout()); + } + private Mono getMonoForHttpResponse(TypeRef type, DaprHttp.Response r) { try { if (type == null) { diff --git a/sdk/src/main/java/io/dapr/client/DaprHttp.java b/sdk/src/main/java/io/dapr/client/DaprHttp.java index fd7accd998..e091a6587a 100644 --- a/sdk/src/main/java/io/dapr/client/DaprHttp.java +++ b/sdk/src/main/java/io/dapr/client/DaprHttp.java @@ -185,6 +185,22 @@ public int getStatusCode() { this.httpClient = httpClient; } + URI getBaseUri() { + return uri; + } + + String getDaprApiToken() { + return daprApiToken; + } + + Duration getReadTimeout() { + return readTimeout; + } + + HttpClient getHttpClient() { + return httpClient; + } + /** * Invokes an API asynchronously without payload that returns a text payload. * diff --git a/sdk/src/main/java/io/dapr/client/DaprInvokeHttpClient.java b/sdk/src/main/java/io/dapr/client/DaprInvokeHttpClient.java new file mode 100644 index 0000000000..171f49fe19 --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/DaprInvokeHttpClient.java @@ -0,0 +1,140 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandler; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +/** + * An HTTP client pre-configured to invoke a specific Dapr application via the + * service invocation API. + * + *

Obtained via {@link DaprClient#invokeHttpClient(String)}. Relative paths + * passed to {@link #newRequestBuilder(String)} resolve against + * {@code {daprHttpEndpoint}/v1.0/invoke/{appId}/method/} and the configured + * {@code dapr-api-token} header is attached automatically when present. + * + *

Example: + *

{@code
+ * DaprInvokeHttpClient invoker = daprClient.invokeHttpClient("orderprocessor");
+ *
+ * HttpRequest request = invoker.newRequestBuilder("orders")
+ *     .header("Content-Type", "application/json")
+ *     .POST(HttpRequest.BodyPublishers.ofString(json))
+ *     .build();
+ *
+ * HttpResponse response = invoker.send(request, HttpResponse.BodyHandlers.ofString());
+ * }
+ * + *

This class is not {@link AutoCloseable}: the underlying {@link HttpClient} is + * managed by the SDK and shared across all clients created from a single + * {@link DaprClientBuilder}; closing the owning {@link DaprClient} releases it. + */ +public class DaprInvokeHttpClient { + + private final HttpClient httpClient; + private final URI baseUri; + private final String daprApiToken; + private final Duration readTimeout; + + DaprInvokeHttpClient(HttpClient httpClient, URI baseUri, String daprApiToken, Duration readTimeout) { + this.httpClient = Objects.requireNonNull(httpClient, "httpClient"); + this.baseUri = Objects.requireNonNull(baseUri, "baseUri"); + this.daprApiToken = daprApiToken; + this.readTimeout = readTimeout; + } + + /** + * Returns the underlying JDK {@link HttpClient}. Useful as an escape hatch when + * callers need full control over a request (for example to bypass the configured + * base URI for a one-off call). + * + * @return the shared underlying HTTP client. + */ + public HttpClient httpClient() { + return httpClient; + } + + /** + * Returns the base URI against which {@link #newRequestBuilder(String)} resolves + * relative paths. Always ends with a trailing slash, e.g. + * {@code http://localhost:3500/v1.0/invoke/orderprocessor/method/}. + * + * @return the resolved invoke base URI. + */ + public URI baseUri() { + return baseUri; + } + + /** + * Creates an {@link HttpRequest.Builder} pre-bound to the Dapr invoke URL for the + * configured app id, with the {@code dapr-api-token} header attached (when one is + * configured) and the SDK's HTTP read timeout applied. + * + *

The {@code relativePath} is resolved against {@link #baseUri()} via + * {@link URI#resolve(String)}. Per {@link URI#resolve(String)} semantics, a leading + * slash replaces the entire path, so callers should typically pass a path + * without a leading slash (e.g. {@code "orders/42"}). + * + * @param relativePath path appended to the invoke prefix. + * @return a request builder ready to be customized and built. + */ + public HttpRequest.Builder newRequestBuilder(String relativePath) { + Objects.requireNonNull(relativePath, "relativePath"); + HttpRequest.Builder builder = HttpRequest.newBuilder().uri(baseUri.resolve(relativePath)); + if (daprApiToken != null && !daprApiToken.isEmpty()) { + builder.header(Headers.DAPR_API_TOKEN, daprApiToken); + } + if (readTimeout != null && !readTimeout.isZero() && !readTimeout.isNegative()) { + builder.timeout(readTimeout); + } + return builder; + } + + /** + * Sends a request synchronously using the underlying HTTP client. + * Equivalent to {@code httpClient().send(request, bodyHandler)}. + * + * @param request the request to send. + * @param bodyHandler handler for the response body. + * @param the response body type. + * @return the HTTP response. + * @throws IOException if an I/O error occurs. + * @throws InterruptedException if the operation is interrupted. + */ + public HttpResponse send(HttpRequest request, BodyHandler bodyHandler) + throws IOException, InterruptedException { + return httpClient.send(request, bodyHandler); + } + + /** + * Sends a request asynchronously using the underlying HTTP client. + * Equivalent to {@code httpClient().sendAsync(request, bodyHandler)}. + * + * @param request the request to send. + * @param bodyHandler handler for the response body. + * @param the response body type. + * @return a future completing with the HTTP response. + */ + public CompletableFuture> sendAsync(HttpRequest request, BodyHandler bodyHandler) { + return httpClient.sendAsync(request, bodyHandler); + } +} diff --git a/sdk/src/test/java/io/dapr/client/DaprClientHttpTest.java b/sdk/src/test/java/io/dapr/client/DaprClientHttpTest.java index af6791d7e1..e6c9f58064 100644 --- a/sdk/src/test/java/io/dapr/client/DaprClientHttpTest.java +++ b/sdk/src/test/java/io/dapr/client/DaprClientHttpTest.java @@ -633,4 +633,46 @@ public void close() throws Exception { daprClientHttp = buildDaprClient(daprHttp); daprClientHttp.close(); } + + @Test + public void invokeHttpClient_rejectsNullAppId() { + assertThrows(IllegalArgumentException.class, () -> daprClientHttp.invokeHttpClient(null)); + } + + @Test + public void invokeHttpClient_rejectsEmptyAppId() { + assertThrows(IllegalArgumentException.class, () -> daprClientHttp.invokeHttpClient("")); + } + + @Test + public void invokeHttpClient_rejectsBlankAppId() { + assertThrows(IllegalArgumentException.class, () -> daprClientHttp.invokeHttpClient(" ")); + } + + @Test + public void invokeHttpClient_resolvesInvokeBaseUriForAppId() { + DaprInvokeHttpClient invoker = daprClientHttp.invokeHttpClient("orderprocessor"); + + assertEquals( + "http://" + sidecarIp + ":3000/v1.0/invoke/orderprocessor/method/", + invoker.baseUri().toString()); + } + + @Test + public void invokeHttpClient_reusesSharedHttpClient() { + DaprInvokeHttpClient invoker = daprClientHttp.invokeHttpClient("orderprocessor"); + + org.junit.jupiter.api.Assertions.assertSame(httpClient, invoker.httpClient()); + } + + @Test + public void invokeHttpClient_propagatesApiTokenAsHeader() { + DaprHttp tokenedDaprHttp = new DaprHttp(sidecarIp, 3000, "xyz", READ_TIMEOUT, httpClient); + DaprClient client = buildDaprClient(tokenedDaprHttp); + + DaprInvokeHttpClient invoker = client.invokeHttpClient("orderprocessor"); + HttpRequest request = invoker.newRequestBuilder("orders").GET().build(); + + assertEquals("xyz", request.headers().firstValue(Headers.DAPR_API_TOKEN).orElse(null)); + } } diff --git a/sdk/src/test/java/io/dapr/client/DaprInvokeHttpClientTest.java b/sdk/src/test/java/io/dapr/client/DaprInvokeHttpClientTest.java new file mode 100644 index 0000000000..826f9583f4 --- /dev/null +++ b/sdk/src/test/java/io/dapr/client/DaprInvokeHttpClientTest.java @@ -0,0 +1,171 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandler; +import java.net.http.HttpResponse.BodyHandlers; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class DaprInvokeHttpClientTest { + + private static final URI BASE_URI = URI.create("http://localhost:3500/v1.0/invoke/orderprocessor/method/"); + private static final Duration READ_TIMEOUT = Duration.ofSeconds(60); + + private HttpClient httpClient; + + @BeforeEach + public void setUp() { + httpClient = mock(HttpClient.class); + } + + @Test + public void constructor_rejectsNullHttpClient() { + assertThrows(NullPointerException.class, + () -> new DaprInvokeHttpClient(null, BASE_URI, "token", READ_TIMEOUT)); + } + + @Test + public void constructor_rejectsNullBaseUri() { + assertThrows(NullPointerException.class, + () -> new DaprInvokeHttpClient(httpClient, null, "token", READ_TIMEOUT)); + } + + @Test + public void accessors_returnConfiguredValues() { + DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, "token", READ_TIMEOUT); + + assertSame(httpClient, invoker.httpClient()); + assertEquals(BASE_URI, invoker.baseUri()); + } + + @Test + public void newRequestBuilder_resolvesRelativePathAgainstBaseUri() { + DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, null, null); + + HttpRequest request = invoker.newRequestBuilder("orders/42").GET().build(); + + assertEquals("http://localhost:3500/v1.0/invoke/orderprocessor/method/orders/42", + request.uri().toString()); + } + + @Test + public void newRequestBuilder_attachesApiTokenHeaderWhenConfigured() { + DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, "xyz", null); + + HttpRequest request = invoker.newRequestBuilder("orders").GET().build(); + + assertEquals("xyz", request.headers().firstValue(Headers.DAPR_API_TOKEN).orElse(null)); + } + + @Test + public void newRequestBuilder_omitsApiTokenHeaderWhenTokenNull() { + DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, null, null); + + HttpRequest request = invoker.newRequestBuilder("orders").GET().build(); + + assertFalse(request.headers().map().containsKey(Headers.DAPR_API_TOKEN)); + } + + @Test + public void newRequestBuilder_omitsApiTokenHeaderWhenTokenEmpty() { + DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, "", null); + + HttpRequest request = invoker.newRequestBuilder("orders").GET().build(); + + assertFalse(request.headers().map().containsKey(Headers.DAPR_API_TOKEN)); + } + + @Test + public void newRequestBuilder_appliesReadTimeoutWhenConfigured() { + DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, null, READ_TIMEOUT); + + HttpRequest request = invoker.newRequestBuilder("orders").GET().build(); + + assertEquals(Optional.of(READ_TIMEOUT), request.timeout()); + } + + @Test + public void newRequestBuilder_omitsTimeoutWhenNullOrZeroOrNegative() { + HttpRequest nullTimeoutRequest = new DaprInvokeHttpClient(httpClient, BASE_URI, null, null) + .newRequestBuilder("a").GET().build(); + HttpRequest zeroTimeoutRequest = new DaprInvokeHttpClient(httpClient, BASE_URI, null, Duration.ZERO) + .newRequestBuilder("a").GET().build(); + HttpRequest negativeTimeoutRequest = new DaprInvokeHttpClient( + httpClient, BASE_URI, null, Duration.ofSeconds(-1)) + .newRequestBuilder("a").GET().build(); + + assertTrue(nullTimeoutRequest.timeout().isEmpty()); + assertTrue(zeroTimeoutRequest.timeout().isEmpty()); + assertTrue(negativeTimeoutRequest.timeout().isEmpty()); + } + + @Test + public void newRequestBuilder_rejectsNullRelativePath() { + DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, null, null); + + assertThrows(NullPointerException.class, () -> invoker.newRequestBuilder(null)); + } + + @Test + public void send_delegatesToUnderlyingHttpClient() throws Exception { + DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, null, null); + HttpRequest request = invoker.newRequestBuilder("orders").GET().build(); + @SuppressWarnings("unchecked") + HttpResponse stubbedResponse = mock(HttpResponse.class); + BodyHandler handler = BodyHandlers.ofString(); + doReturn(stubbedResponse).when(httpClient).send(same(request), same(handler)); + + HttpResponse response = invoker.send(request, handler); + + assertSame(stubbedResponse, response); + verify(httpClient).send(same(request), same(handler)); + } + + @Test + public void sendAsync_delegatesToUnderlyingHttpClient() { + DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, null, null); + HttpRequest request = invoker.newRequestBuilder("orders").GET().build(); + @SuppressWarnings("unchecked") + HttpResponse stubbedResponse = mock(HttpResponse.class); + BodyHandler handler = BodyHandlers.ofString(); + CompletableFuture> future = CompletableFuture.completedFuture(stubbedResponse); + when(httpClient.sendAsync(same(request), same(handler))).thenReturn(future); + + CompletableFuture> result = invoker.sendAsync(request, handler); + + assertSame(future, result); + verify(httpClient).sendAsync(same(request), any()); + } +}