Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions sdk/src/main/java/io/dapr/client/DaprInvokeHttpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

package io.dapr.client;

import io.dapr.utils.UriUtils;
import io.dapr.utils.Version;

import java.io.IOException;
Expand Down Expand Up @@ -107,17 +108,24 @@ public URI baseUri() {
* HTTP read timeout applied.
*
* <p>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
* <em>without</em> a leading slash (e.g. {@code "orders/42"}).
* {@link UriUtils#resolve(URI, String)} <em>without</em> modification. Per
* {@link URI#resolve(String)} semantics, a leading slash replaces the entire path, so
* callers should typically pass a path <em>without</em> a leading slash (e.g.
* {@code "orders/42"}).
*
* <p>The path is not encoded for you. If a segment may contain characters that are
* illegal in a URI path (e.g. spaces), encode it first with
* {@link UriUtils#encodePath(String)}; passing such characters directly throws
* {@link IllegalArgumentException}.
*
* @param relativePath path appended to the invoke prefix.
* @return a request builder ready to be customized and built.
* @throws IllegalArgumentException if {@code relativePath} contains characters that are
* illegal in a URI; encode it first with {@link UriUtils#encodePath(String)}.
*/
public HttpRequest.Builder newRequestBuilder(String relativePath) {
Objects.requireNonNull(relativePath, "relativePath");
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(baseUri.resolve(relativePath))
.uri(UriUtils.resolve(baseUri, relativePath))
.header(Headers.DAPR_USER_AGENT, Version.getSdkVersion());
if (daprApiToken != null && !daprApiToken.isEmpty()) {
builder.header(Headers.DAPR_API_TOKEN, daprApiToken);
Expand Down
98 changes: 98 additions & 0 deletions sdk/src/main/java/io/dapr/utils/UriUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* 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.utils;

import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Objects;

/**
* Utilities for building and resolving the URIs used by the Dapr HTTP APIs.
*/
public class UriUtils {

/**
* Resolves {@code relativePath} against {@code baseUri} via {@link URI#resolve(String)}
* without modifying it. If {@code relativePath} contains characters that are illegal in a
* URI (e.g. spaces), this throws {@link IllegalArgumentException} with guidance to
* {@link #encodePath(String)}.
*
* <p>The raw {@code relativePath} is never echoed in the thrown exception (it may contain
* sensitive identifiers); only the offending index is surfaced.
*
* @param baseUri the base URI to resolve against.
* @param relativePath the relative path to resolve.
* @return the resolved URI.
* @throws IllegalArgumentException if {@code relativePath} contains characters that are
* illegal in a URI; encode it first with {@link #encodePath(String)}.
*/
public static URI resolve(URI baseUri, String relativePath) {
Objects.requireNonNull(baseUri, "baseUri");
Objects.requireNonNull(relativePath, "relativePath");
try {
return baseUri.resolve(relativePath);
} catch (IllegalArgumentException exception) {
// The wrapped URISyntaxException echoes the full relativePath in its message, which may
// contain sensitive identifiers; surface only the offending index, never the input, and
// do not chain the cause.
Throwable cause = exception.getCause();
String position = (cause instanceof URISyntaxException
&& ((URISyntaxException) cause).getIndex() >= 0)
? " at index " + ((URISyntaxException) cause).getIndex() : "";
throw new IllegalArgumentException(
"relativePath contains a character that is illegal in a URI" + position
+ " (e.g. a space). Encode it first with UriUtils.encodePath(String).");
}
}

/**
* Percent-encodes each {@code /}-delimited segment of the path portion of
* {@code relativePath} (preserving the separators) so it can be safely passed to
* {@link #resolve(URI, String)} when a segment contains characters that are illegal in a
* URI path, such as spaces. Mirrors the per-segment encoding the deprecated
* {@code DaprClient.invokeMethod} APIs applied internally.
*
* <p>The query string (from the first {@code ?} onwards) is appended unchanged and must be
* pre-encoded by the caller. A leading slash is preserved, so {@link URI#resolve(String)}
* still treats it as replacing the entire base path.
*
* @param relativePath the raw, unencoded path to encode.
* @return {@code relativePath} with each path segment percent-encoded.
*/
public static String encodePath(String relativePath) {
Objects.requireNonNull(relativePath, "relativePath");
int queryStart = relativePath.indexOf('?');
String path = queryStart < 0 ? relativePath : relativePath.substring(0, queryStart);
String query = queryStart < 0 ? "" : relativePath.substring(queryStart);

StringBuilder encoded = new StringBuilder(path.length() + 16);
int start = 0;
for (int i = 0; i <= path.length(); i++) {
if (i == path.length() || path.charAt(i) == '/') {
String segment = path.substring(start, i);
if (!segment.isEmpty()) {
encoded.append(URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20"));
}
if (i < path.length()) {
encoded.append('/');
}
start = i + 1;
}
}

return encoded.append(query).toString();
}
}
37 changes: 37 additions & 0 deletions sdk/src/test/java/io/dapr/client/DaprInvokeHttpClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

package io.dapr.client;

import io.dapr.utils.UriUtils;
import io.dapr.utils.Version;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand All @@ -29,6 +30,7 @@

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
Expand Down Expand Up @@ -81,6 +83,41 @@ public void newRequestBuilder_resolvesRelativePathAgainstBaseUri() {
request.uri().toString());
}

@Test
public void newRequestBuilder_acceptsEncodedPath() {
DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, null, null);

HttpRequest request = invoker.newRequestBuilder(
UriUtils.encodePath("api/some-resource/Name With Spaces/versions")).GET().build();

assertEquals(
"http://localhost:3500/v1.0/invoke/orderprocessor/method/"
+ "api/some-resource/Name%20With%20Spaces/versions",
request.uri().toString());
}

@Test
public void newRequestBuilder_rejectsIllegalCharactersWithoutLeakingInput() {
DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, null, null);

IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> invoker.newRequestBuilder("orders/secret-token-abc 123"));

assertTrue(exception.getMessage().contains("encodePath"));
// The raw input must not leak into the message or via the wrapped cause.
assertFalse(exception.getMessage().contains("secret-token-abc"));
assertNull(exception.getCause());
}

@Test
public void newRequestBuilder_leadingSlashStillReplacesEntirePath() {
DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, null, null);

HttpRequest request = invoker.newRequestBuilder("/v1.0/healthz/outbound").GET().build();

assertEquals("http://localhost:3500/v1.0/healthz/outbound", request.uri().toString());
}

@Test
public void newRequestBuilder_attachesSdkUserAgentHeader() {
DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, null, null);
Expand Down
74 changes: 74 additions & 0 deletions sdk/src/test/java/io/dapr/utils/UriUtilsTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* 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.utils;

import org.junit.jupiter.api.Test;

import java.net.URI;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

public class UriUtilsTest {

private static final URI BASE_URI = URI.create("http://localhost:3500/v1.0/invoke/orderprocessor/method/");

@Test
public void encodePath_encodesSegmentsWithSpacesPreservingSeparators() {
assertEquals("api/some-resource/Name%20With%20Spaces/versions",
UriUtils.encodePath("api/some-resource/Name With Spaces/versions"));
}

@Test
public void encodePath_encodesIllegalCharsAndAppendsQueryUnchanged() {
assertEquals("orders/a%20b/c%23d?status=open&page=2",
UriUtils.encodePath("orders/a b/c#d?status=open&page=2"));
}

@Test
public void encodePath_preservesLeadingSlash() {
assertEquals("/v1.0/healthz/outbound", UriUtils.encodePath("/v1.0/healthz/outbound"));
}

@Test
public void encodePath_rejectsNull() {
assertThrows(NullPointerException.class, () -> UriUtils.encodePath(null));
}

@Test
public void resolve_resolvesRelativePathAgainstBaseUri() {
assertEquals(URI.create("http://localhost:3500/v1.0/invoke/orderprocessor/method/orders/42"),
UriUtils.resolve(BASE_URI, "orders/42"));
}

@Test
public void resolve_rejectsNullArguments() {
assertThrows(NullPointerException.class, () -> UriUtils.resolve(null, "orders/42"));
assertThrows(NullPointerException.class, () -> UriUtils.resolve(BASE_URI, null));
}

@Test
public void resolve_rejectsIllegalCharactersWithoutLeakingInput() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> UriUtils.resolve(BASE_URI, "orders/secret-token-abc 123"));

assertTrue(exception.getMessage().contains("encodePath"));
// The raw input must not leak into the message or via the wrapped cause.
assertFalse(exception.getMessage().contains("secret-token-abc"));
assertNull(exception.getCause());
}
}
Loading