From 359420ce548e5ae886ea7a83c94bad8e59fe5c6c Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Thu, 12 Nov 2026 20:56:58 +0100 Subject: [PATCH] Jakarta REST client: do not produce '//' when base URI ends in '/' RestInvocationHandler appends the resolved resource path after the base URI's path segments. A base URI ending in '/' has an empty last segment, so the append produced a double slash. Drop that trailing empty segment before appending, the same way ResourceIfaceScanner#join already does when joining a resource class's path with its method's. URIBuilder itself is unchanged: an empty path segment is meaningful per RFC 3986 and this is specific to how the REST client joins two already-resolved paths, not to path segment manipulation in general. Follow-up to apache/httpcomponents-core#745, which traced the bug to this exact join but put the fix in the wrong layer (URIBuilder). --- .../http/rest/RestInvocationHandler.java | 13 ++++++++++++- .../http/rest/RestClientIntegrationTest.java | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestInvocationHandler.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestInvocationHandler.java index 5df10f7e18..de77035139 100644 --- a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestInvocationHandler.java +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestInvocationHandler.java @@ -157,7 +157,18 @@ private Object executeRequest(final ResourceMethod rm, pathSegments.add(pathTemplate.getSegment()); } } - uriBuilder.appendPathSegments(pathSegments); + if (!pathSegments.isEmpty()) { + // A base URI ending in '/' (as many are written) has an empty last path + // segment. Appending after it would produce '//'; drop it first, exactly + // as ResourceIfaceScanner#join already does when joining a resource class's + // path with its method's path. + final List baseSegments = uriBuilder.getPathSegments(); + if (!baseSegments.isEmpty() && baseSegments.get(baseSegments.size() - 1).isEmpty()) { + baseSegments.remove(baseSegments.size() - 1); + } + baseSegments.addAll(pathSegments); + uriBuilder.setPathSegments(baseSegments); + } uriBuilder.addParameters(queryParams); requestUri = uriBuilder.build(); } catch (final URISyntaxException ex) { diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientIntegrationTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientIntegrationTest.java index b1a1037b00..c7af1f9855 100644 --- a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientIntegrationTest.java +++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientIntegrationTest.java @@ -160,6 +160,24 @@ void readsFormUrlEncodedResponseAsNameValuePairs() throws Exception { assertThat(pairs.get(1).getValue()).isEqualTo("en"); } + @Test + void getBook_withTrailingSlashBaseUri_doesNotProduceDoubleSlash() throws Exception { + // A base URI ending in '/', as many are written, must not combine with the resource + // path to produce '//books/...': the server only has a context registered at + // "/books", so a leading double slash would not match it and the call would fail. + final URI trailingSlashBaseUri = new URI(baseUri.toString() + "/"); + final BookResource client = RestClientBuilder.newBuilder() + .baseUri(trailingSlashBaseUri) + .httpClient(httpClient) + .build(BookResource.class); + + final Book fetched = client.getBook("abc-123", "en", "token-1"); + + assertThat(fetched).isNotNull(); + assertThat(fetched.id).isEqualTo("abc-123"); + assertThat(fetched.lang).isEqualTo("en"); + } + @Test void rejectsNonFormResponseForNameValuePairReturnType() { final BookResource client = RestClientBuilder.newBuilder()