Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
298d9ec
add new module (copy of odata-client)
Jonas-Isr Mar 10, 2026
3f5fd26
initial change to hc5 classes (excluding tests)
Jonas-Isr Mar 16, 2026
18f9d52
intermediate test migration
Jonas-Isr Mar 27, 2026
aa3e921
tests compile (but many fail)
Jonas-Isr Apr 30, 2026
e5b3ac0
tests compile (but many fail)
Jonas-Isr May 5, 2026
c292c3f
implement fixes
Jonas-Isr May 5, 2026
27890e3
re-implement CSRF token handling
Jonas-Isr May 5, 2026
e3d8059
codestyle and fix serializable
Jonas-Isr May 6, 2026
d8361d7
fix most remaining tests
Jonas-Isr May 6, 2026
731e7a0
redo uir query merging (introduce interface) and csrf token handling …
Jonas-Isr May 7, 2026
8eda731
small fixes and code style
Jonas-Isr May 8, 2026
3edeef7
Merge branch 'main' into odata-client-hc5
Jonas-Isr May 8, 2026
51930e7
add javadoc
Jonas-Isr May 8, 2026
ae93c6e
increase Cloud SDK version by hand
Jonas-Isr May 8, 2026
303b6dd
improve CSRF token interceptor logic
Jonas-Isr May 12, 2026
47c1587
small improvements and more tests
Jonas-Isr May 18, 2026
22d9911
make csrf token handling clearer
Jonas-Isr May 19, 2026
77e422c
remove some warnings
Jonas-Isr May 19, 2026
b55c15d
Merge branch 'main' into odata-client-hc5
Jonas-Isr May 19, 2026
7a8b50c
formatter
Jonas-Isr May 19, 2026
1b18f40
Merge remote-tracking branch 'origin/odata-client-hc5' into odata-cli…
Jonas-Isr May 19, 2026
9ad0c52
formatter
Jonas-Isr May 19, 2026
b6a37a6
PMD
Jonas-Isr May 19, 2026
bd6635b
update @since tag
Jonas-Isr May 20, 2026
6ab4f8d
more pmd
Jonas-Isr May 20, 2026
4c2950f
spotbugs
Jonas-Isr May 20, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import java.net.URI;
import java.net.URISyntaxException;

import javax.annotation.Nonnull;

import org.apache.hc.client5.http.config.Configurable;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
Expand All @@ -27,7 +29,7 @@
* and it will append the url configured in the destination.
*/
@Slf4j
class ApacheHttpClient5Wrapper extends CloseableHttpClient implements Configurable
class ApacheHttpClient5Wrapper extends CloseableHttpClient implements Configurable, UriQueryMerger
{
private final CloseableHttpClient httpClient;
@Getter( AccessLevel.PACKAGE )
Expand Down Expand Up @@ -127,4 +129,14 @@ public RequestConfig getConfig()
{
return requestConfig;
}

@Nonnull
@Override
public URI mergeRequestUri( @Nonnull final URI requestUri )
{
final UriPathMerger merger = new UriPathMerger();
final URI merged = merger.merge(destination.getUri(), requestUri);
final String queryString = String.join("&", QueryParamGetter.getQueryParameters(destination));
return merger.merge(merged, URI.create("/?" + queryString));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.sap.cloud.sdk.cloudplatform.connectivity;

import java.io.IOException;
import java.net.URI;
import java.util.Set;
import java.util.regex.Pattern;

import javax.annotation.Nonnull;

import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.classic.methods.HttpHead;
import org.apache.hc.core5.http.EntityDetails;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.HttpRequestInterceptor;
import org.apache.hc.core5.http.protocol.HttpContext;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
class CsrfTokenInterceptor implements HttpRequestInterceptor
{
static final String X_CSRF_TOKEN_HEADER_KEY = "x-csrf-token";
private static final String X_CSRF_TOKEN_FETCH_VALUE = "fetch";

private static final Set<String> MUTATING_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
private static final Pattern NON_PRINTABLE_CHARS = Pattern.compile("[^ -~]");

@Nonnull
private final HttpClient httpClient;

@Override
public
void
process( @Nonnull final HttpRequest request, final EntityDetails entityDetails, final HttpContext context )
throws HttpException,
IOException
{
if( !MUTATING_METHODS.contains(request.getMethod().toUpperCase()) ) {
return;
}

if( request.containsHeader(X_CSRF_TOKEN_HEADER_KEY) ) {
log.debug("CSRF token already present in request, skipping retrieval.");
return;
}

final URI requestUri;
try {
requestUri = request.getUri();
}
catch( final Exception e ) {
log.debug("Failed to determine request URI for CSRF token fetch, skipping.", e);
return;
}

final URI csrfFetchUri = deriveServiceRootUri(requestUri);
final HttpHead headRequest = new HttpHead(csrfFetchUri);
headRequest.addHeader(X_CSRF_TOKEN_HEADER_KEY, X_CSRF_TOKEN_FETCH_VALUE);

try {
final String token = httpClient.execute(headRequest, response -> {
final Header header = response.getFirstHeader(X_CSRF_TOKEN_HEADER_KEY);
if( header == null || header.getValue() == null ) {
log
.warn(
"Target system did not respond with a {} header. "
+ "The subsequent request may fail if a CSRF token is required.",
X_CSRF_TOKEN_HEADER_KEY);
return null;
}
return NON_PRINTABLE_CHARS.matcher(header.getValue()).replaceAll("");
});

if( token != null ) {
log.debug("Successfully retrieved CSRF token, adding to request.");
request.addHeader(X_CSRF_TOKEN_HEADER_KEY, token);
}
}
catch( final Exception e ) {
log
.warn(
"CSRF token retrieval failed: the HEAD request was not successful. "
+ "The subsequent request may fail if a CSRF token is required.",
e);
}
}

/**
* Derives the service root URI from the full request URI by truncating the path at the first OData resource
* segment. This matches the HC4 behavior where the CSRF token HEAD request was always sent to the service path root
* rather than the specific resource path.
* <p>
* The service root is identified as the path up to and including the trailing slash before the first resource
* segment. Example: {@code http://host/service/$batch} → {@code http://host/service/},
* {@code http://host/service/Entity} → {@code http://host/service/}
*/
@Nonnull
static URI deriveServiceRootUri( @Nonnull final URI requestUri )
{
final String path = requestUri.getRawPath();
// Service root is everything up to and including the trailing slash before the first resource segment.
// Find the last '/' that is followed by at least one more character (i.e., there is a resource segment).
final int lastSlash = path.lastIndexOf('/');
// If the path ends with '/' already (e.g. "/service/"), use it as-is.
// Otherwise, strip the last segment (e.g. "/service/Entity" -> "/service/", "/service/$batch" -> "/service/").
final String servicePath =
(lastSlash >= 0 && lastSlash < path.length() - 1) ? path.substring(0, lastSlash + 1) : path;
try {
return new URI(requestUri.getScheme(), requestUri.getAuthority(), servicePath, null, null);
}
catch( final Exception e ) {
log.debug("Failed to derive service root URI, falling back to full request URI.", e);
return requestUri;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.security.GeneralSecurityException;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
Expand Down Expand Up @@ -104,7 +105,12 @@ private CloseableHttpClient buildHttpClient(
builder.addRequestInterceptorFirst(requestInterceptor);
}

return builder.build();
final AtomicReference<CloseableHttpClient> holder = new AtomicReference<>();
builder
.addRequestInterceptorLast(
( req, entity, ctx ) -> new CsrfTokenInterceptor(holder.get()).process(req, entity, ctx));
holder.set(builder.build());
return holder.get();
}

@Nonnull
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.sap.cloud.sdk.cloudplatform.connectivity;

import java.net.URI;

import javax.annotation.Nonnull;

/**
* Interface to resolve the request URI for a given request. Used to determine the destination-contributed query
* parameters so that next-link pagination can strip duplicate parameters.
*/
@FunctionalInterface
public interface UriQueryMerger
{
/**
* Returns the fully-merged request URI for the given relative request URI. Merges the destination base URL,
* destination URL query parameters, and destination property query parameters into the URI.
*
* @param requestUri
* The relative request URI to merge.
* @return The merged request URI.
*/
@Nonnull
URI mergeRequestUri( @Nonnull URI requestUri );
}
Loading