diff --git a/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/NetworkVerifier.java b/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/NetworkVerifier.java
new file mode 100644
index 000000000..a22716c36
--- /dev/null
+++ b/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/NetworkVerifier.java
@@ -0,0 +1,90 @@
+package com.sap.cloud.sdk.cloudplatform.connectivity;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.UnknownHostException;
+
+import javax.annotation.Nonnull;
+
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * NetworkVerifier that performs TCP socket-based connectivity verification.
+ *
+ *
+ * This implementation uses standard Java socket connections to verify that a remote host and port combination is
+ * reachable and accepting connections. It establishes a temporary TCP connection to the target endpoint and immediately
+ * closes it upon successful establishment.
+ *
+ *
+ * Verification Process:
+ *
+ * - Creates a new TCP socket
+ * - Attempts to connect to the specified host and port with a timeout
+ * - Immediately closes the connection if successful
+ * - Throws appropriate exceptions for failures
+ *
+ *
+ *
+ * Timeout Configuration: The verification uses a fixed timeout of {@value #HOST_REACH_TIMEOUT}
+ * milliseconds to prevent indefinite blocking on unreachable endpoints.
+ *
+ *
+ * Error Handling:
+ *
+ * - {@link UnknownHostException} - Host cannot be resolved to an IP address
+ * - {@link IOException} - Network connectivity issues or connection refused
+ *
+ *
+ * @see TransparentProxy
+ * @since 5.24.0
+ */
+@Slf4j
+class NetworkVerifier
+{
+ private static final int HOST_REACH_TIMEOUT = 5000;
+
+ /**
+ * {@inheritDoc}
+ *
+ *
+ * This implementation creates a TCP socket connection to the specified host and port to verify connectivity. The
+ * connection is immediately closed after successful establishment.
+ *
+ * @param host
+ * {@inheritDoc}
+ * @param port
+ * {@inheritDoc}
+ * @throws DestinationAccessException
+ * {@inheritDoc}
+ *
+ * Specific error conditions:
+ *
+ * - Host resolution failure - when DNS lookup fails
+ * - Connection failure - when host is unreachable or port is closed
+ * - Network timeouts - when connection attempt exceeds timeout
+ *
+ */
+ void verifyHostConnectivity( @Nonnull final String host, final int port )
+ throws DestinationAccessException
+ {
+ log.info("Verifying that transparent proxy host is reachable on {}:{}", host, port);
+ try( Socket socket = new Socket() ) {
+ socket.connect(new InetSocketAddress(host, port), HOST_REACH_TIMEOUT);
+ log.info("Successfully verified that transparent proxy host is reachable on {}:{}", host, port);
+ }
+ catch( final UnknownHostException e ) {
+ throw new DestinationAccessException(
+ String.format("Host [%s] could not be resolved. Caused by: %s", host, e.getMessage()),
+ e);
+ }
+ catch( final IOException e ) {
+ throw new DestinationAccessException(
+ String.format("Host [%s] on port [%d] is not reachable. Caused by: %s", host, port, e.getMessage()),
+ e);
+ }
+ }
+}
diff --git a/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxy.java b/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxy.java
new file mode 100644
index 000000000..89433808b
--- /dev/null
+++ b/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxy.java
@@ -0,0 +1,461 @@
+package com.sap.cloud.sdk.cloudplatform.connectivity;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Supplier;
+
+import javax.annotation.Nonnull;
+
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.apache.http.HttpMessage;
+import org.apache.http.HttpResponse;
+import org.apache.http.HttpStatus;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpHead;
+
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationNotFoundException;
+import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceConfiguration;
+import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceDecorator;
+import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceRuntimeException;
+import com.sap.cloud.sdk.cloudplatform.tenant.DefaultTenant;
+import com.sap.cloud.sdk.cloudplatform.tenant.Tenant;
+import com.sap.cloud.sdk.cloudplatform.tenant.TenantAccessor;
+import com.sap.cloud.sdk.cloudplatform.tenant.exception.TenantAccessException;
+
+import io.vavr.control.Option;
+import io.vavr.control.Try;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * A transparent proxy loader that enables routing traffic through a single registered gateway host.
+ *
+ *
+ * This class provides a mechanism to register a proxy gateway that will handle all destination requests transparently.
+ * Once registered, all destination lookups will be routed through the configured gateway host and port.
+ *
+ *
+ * Key Features:
+ *
+ * - Single gateway registration - only one proxy can be registered at a time
+ * - Host validation - ensures hosts don't contain paths and are reachable
+ * - Automatic scheme normalization - defaults to HTTP if no scheme provided
+ * - Network connectivity validation before registration
+ *
+ *
+ *
+ * Usage Example:
+ *
+ *
{@code
+ * // Register with default port 80
+ * TransparentProxy.register("gateway.svc.cluster.local");
+ *
+ * // Register with custom port
+ * TransparentProxy.register("http://gateway.svc.cluster.local", 8080);
+ * }
+ *
+ *
+ * Thread Safety: This class uses static state and is not thread-safe. Registration should be performed
+ * during application initialization.
+ *
+ * @since 5.24.0
+ */
+@Slf4j
+public class TransparentProxy implements DestinationLoader
+{
+ private static final ResilienceConfiguration.TimeLimiterConfiguration DEFAULT_TIME_LIMITER =
+ ResilienceConfiguration.TimeLimiterConfiguration.of().timeoutDuration(Duration.ofSeconds(10));
+
+ private static final String X_ERROR_INTERNAL_CODE_HEADER = "x-error-internal-code";
+ private static final String X_ERROR_ORIGIN_HEADER = "x-error-origin";
+ private static final String X_ERROR_MESSAGE_HEADER = "x-error-message";
+ private static final String SET_COOKIE_HEADER = "Set-Cookie";
+ private static final Integer DEFAULT_PORT = 80;
+ private static final String SCHEME_SEPARATOR = "://";
+ private static final String HTTP_SCHEME = org.apache.http.HttpHost.DEFAULT_SCHEME_NAME + SCHEME_SEPARATOR;
+ private static final String PORT_SEPARATOR = ":";
+ private static final String HOST_CONTAINS_PATH_ERROR_MESSAGE_TEMPLATE =
+ "Host '%s' contains a path '%s'. Paths are not allowed in host registration.";
+ private static final ResilienceConfiguration resilienceConfiguration =
+ createResilienceConfiguration("destinationverifier", DEFAULT_TIME_LIMITER);
+ private static final String FAILED_TO_VERIFY_DESTINATION = "Failed to verify destination. ";
+ private static final String NO_TENANT_PROVIDED_ERROR_MESSAGE =
+ "No current tenant defined and no provider tenant id configured. Transparent proxy always requires an explicit tenant ID. Please use register(host, providerID) in case provider tenant access is intended";
+ static String uri;
+ static String providerTenantId;
+ static NetworkVerifier networkVerifier = new NetworkVerifier();
+
+ /**
+ * Registers a transparent proxy gateway using the default port 80.
+ *
+ *
+ * This method registers the specified host as a transparent proxy gateway that will handle all subsequent
+ * destination requests. The host will be validated for reachability and must not contain any path components.
+ *
+ *
+ * If no scheme is provided, HTTP will be used by default. The final URI will be constructed as:
+ * {@code :80}
+ *
+ * @param host
+ * the gateway host to register (e.g., "gateway.svc.cluster.local") Must not contain paths or be null
+ * @throws DestinationAccessException
+ * if the proxy is already registered, the host contains a path, or the host is not reachable on port 80
+ * @throws IllegalArgumentException
+ * if host is null
+ * @see #register(String, Integer)
+ */
+ public static void register( @Nonnull final String host )
+ {
+ registerLoader(host, DEFAULT_PORT, null);
+ }
+
+ /**
+ * Registers a transparent proxy gateway with a specified port.
+ *
+ *
+ * This method registers the specified host and port as a transparent proxy gateway that will handle all subsequent
+ * destination requests. The host will be validated for reachability on the specified port and must not contain any
+ * path components.
+ *
+ *
+ * If no scheme is provided, HTTP will be used by default. The final URI will be constructed as:
+ * {@code :}
+ *
+ * @param host
+ * the gateway host to register (e.g., "gateway" or "...") Must not contain
+ * paths or be null
+ * @param port
+ * the port number to use for the gateway connection. Must not be null and should be a valid port number
+ * (1-65535)
+ * @throws DestinationAccessException
+ * if the proxy is already registered, the host contains a path, or the host is not reachable on the
+ * specified port
+ * @throws IllegalArgumentException
+ * if host or port is null
+ * @see #register(String)
+ */
+ public static void register( @Nonnull final String host, @Nonnull final Integer port )
+ {
+ registerLoader(host, port, null);
+ }
+
+ /**
+ * Registers a transparent proxy gateway with a specified port and provider tenant ID.
+ *
+ *
+ * This method registers the specified host and port as a transparent proxy gateway that will handle all subsequent
+ * destination requests. The host will be validated for reachability on the specified port and must not contain any
+ * path components. The provider tenant ID serves as a fallback when the current tenant cannot be accessed during
+ * destination preparation.
+ *
+ *
+ * If no scheme is provided, HTTP will be used by default. The final URI will be constructed as:
+ * {@code :}
+ *
+ *
+ * The provider tenant ID is particularly useful in scenarios where the transparent proxy needs to operate in
+ * contexts where tenant information is not readily available, providing a default tenant for authentication and
+ * authorization purposes.
+ *
+ * @param host
+ * the gateway host to register (e.g., "gateway" or "...") Must not contain
+ * paths or be null
+ * @param port
+ * the port number to use for the gateway connection. Must not be null and should be a valid port number
+ * (1-65535)
+ * @param providerTenantId
+ * the provider tenant ID to use as a fallback when the current tenant cannot be accessed. Must not be
+ * null
+ * @throws DestinationAccessException
+ * if the proxy is already registered, the host contains a path, or the host is not reachable on the
+ * specified port
+ * @throws IllegalArgumentException
+ * if host, port, or providerTenantId is null
+ * @see #register(String)
+ * @see #register(String, Integer)
+ */
+
+ public static
+ void
+ register( @Nonnull final String host, @Nonnull final Integer port, @Nonnull final String providerTenantId )
+ {
+ registerLoader(host, port, providerTenantId);
+ }
+
+ /**
+ * Registers a transparent proxy gateway using the default port 80 with a provider tenant ID.
+ *
+ *
+ * This method registers the specified host as a transparent proxy gateway that will handle all subsequent
+ * destination requests, using the default port 80. The host will be validated for reachability and must not contain
+ * any path components. The provider tenant ID serves as a fallback when the current tenant cannot be accessed
+ * during destination preparation.
+ *
+ *
+ * If no scheme is provided, HTTP will be used by default. The final URI will be constructed as:
+ * {@code :80}
+ *
+ *
+ * The provider tenant ID is particularly useful in scenarios where the transparent proxy needs to operate in
+ * contexts where tenant information is not readily available, providing a default tenant for authentication and
+ * authorization purposes.
+ *
+ * @param host
+ * the gateway host to register (e.g., "gateway.svc.cluster.local") Must not contain paths or be null
+ * @param providerTenantId
+ * the provider tenant ID to use as a fallback when the current tenant cannot be accessed. Must not be
+ * null
+ * @throws DestinationAccessException
+ * if the proxy is already registered, the host contains a path, or the host is not reachable on port 80
+ * @throws IllegalArgumentException
+ * if host or providerTenantId is null
+ * @see #register(String)
+ * @see #register(String, Integer)
+ * @see #register(String, Integer, String)
+ */
+ public static void register( @Nonnull final String host, @Nonnull final String providerTenantId )
+ {
+ registerLoader(host, DEFAULT_PORT, providerTenantId);
+ }
+
+ private static void registerLoader( @Nonnull final String host, final Integer port, final String providerTenantId )
+ {
+ if( uri != null ) {
+ throw new DestinationAccessException(
+ "TransparentProxy is already registered. Only one registration is allowed.");
+ }
+
+ final String normalizedHost = normalizeHostWithScheme(host);
+ try {
+ final String hostForVerification = getHostForVerification(host, normalizedHost);
+ networkVerifier.verifyHostConnectivity(hostForVerification, port);
+ }
+ catch( final URISyntaxException e ) {
+ throw new DestinationAccessException(
+ String.format("Invalid host format: [%s]. Caused by: %s", host, e.getMessage()),
+ e);
+ }
+ uri = String.format("%s%s%d", normalizedHost, PORT_SEPARATOR, port);
+ TransparentProxy.providerTenantId = providerTenantId;
+ DestinationAccessor.prependDestinationLoader(new TransparentProxy());
+ }
+
+ @Nonnull
+ private static String getHostForVerification( @Nonnull final String host, final String normalizedHost )
+ throws URISyntaxException
+ {
+ final URI parsedUri = new URI(normalizedHost);
+
+ final String path = parsedUri.getPath();
+ if( path != null && !path.isEmpty() ) {
+ throw new DestinationAccessException(String.format(HOST_CONTAINS_PATH_ERROR_MESSAGE_TEMPLATE, host, path));
+ }
+
+ final String hostForVerification = parsedUri.getHost();
+ if( hostForVerification == null ) {
+ throw new DestinationAccessException(String.format("Invalid host format: [%s]", host));
+ }
+ return hostForVerification;
+ }
+
+ @Nonnull
+ private static String normalizeHostWithScheme( @Nonnull final String host )
+ {
+ if( host.contains(SCHEME_SEPARATOR) ) {
+ return host;
+ }
+ return HTTP_SCHEME + host;
+ }
+
+ /**
+ *
+ * @param destination
+ * the destination to use
+ */
+ private static TransparentProxyDestination verifyDestination(
+ @Nonnull final TransparentProxyDestination destination )
+ {
+ final HttpClient httpClient = HttpClientAccessor.getHttpClient(destination);
+ final URI destinationUri = URI.create(uri);
+ final HttpHead headRequest = new HttpHead(destinationUri);
+ final String destinationName = getDestinationName(destination, destinationUri);
+
+ log
+ .debug(
+ "Performing HEAD request to destination with name {} to verify the destination exists",
+ destinationName);
+ final Supplier tpDestinationVerifierSupplier = prepareSupplier(httpClient, headRequest);
+ HttpResponse response = null;
+ try {
+ response = ResilienceDecorator.executeSupplier(tpDestinationVerifierSupplier, resilienceConfiguration);
+ verifyTransparentProxyResponse(response, destinationName);
+ }
+ catch( final ResilienceRuntimeException e ) {
+ if( hasCauseAssignableFrom(e, DestinationNotFoundException.class) ) {
+ throw new DestinationNotFoundException(e);
+ }
+ throw new DestinationAccessException(e);
+ }
+ finally {
+ if( response != null ) {
+ try {
+ org.apache.http.util.EntityUtils.consume(response.getEntity());
+ }
+ catch( IOException e ) {
+ log.warn("Failed to close HTTP response", e);
+ }
+ }
+ }
+
+ return destination;
+ }
+
+ private static boolean hasCauseAssignableFrom( @Nonnull final Throwable t, @Nonnull final Class> cls )
+ {
+ return ExceptionUtils.getThrowableList(t).stream().map(Throwable::getClass).anyMatch(cls::isAssignableFrom);
+ }
+
+ private static
+ String
+ getDestinationName( @Nonnull final TransparentProxyDestination destination, final URI destinationUri )
+ {
+ return destination
+ .getHeaders(destinationUri)
+ .stream()
+ .filter(
+ header -> TransparentProxyDestination.DESTINATION_NAME_HEADER_KEY.equalsIgnoreCase(header.getName()))
+ .findFirst()
+ .map(Header::getValue)
+ .orElseThrow(
+ () -> new IllegalStateException("Destination name header in Transparent Proxy loader is missing."));
+ }
+
+ private static void verifyTransparentProxyResponse( final HttpResponse response, final String destinationName )
+ {
+ if( response == null ) {
+ throw new DestinationAccessException(FAILED_TO_VERIFY_DESTINATION + "Response is null.");
+ }
+ if( response.containsHeader(SET_COOKIE_HEADER) ) {
+ final org.apache.http.Header[] header = response.getHeaders(SET_COOKIE_HEADER);
+ final List cookieNames = Arrays.stream(header).map(h -> h.getValue().split("=", 2)[0]).toList();
+ log
+ .warn(
+ "received set-cookie headers as part of destination health check. This is unexpected and may have side effects for your application. The following cookies were set: {}",
+ cookieNames);
+ }
+
+ final int statusCode = response.getStatusLine().getStatusCode();
+ final String errorInternalCode = getHeaderValue(response, X_ERROR_INTERNAL_CODE_HEADER);
+ final String errorMessage = getHeaderValue(response, X_ERROR_MESSAGE_HEADER);
+ final String errorOrigin = getHeaderValue(response, X_ERROR_ORIGIN_HEADER);
+
+ log
+ .debug(
+ "HEAD request to destination with name {} returned status code: {}, x-error-internal-code: {}",
+ destinationName,
+ statusCode,
+ errorInternalCode);
+ if( statusCode == HttpStatus.SC_BAD_GATEWAY
+ && Integer.toString(HttpStatus.SC_NOT_FOUND).equals(errorInternalCode) ) {
+ throw new DestinationNotFoundException(errorMessage);
+ }
+ if( !"".equals(errorOrigin) ) {
+ final String detailedErrorMessage =
+ String
+ .format(
+ "%s Destination name: [%s], Origin: [%s], Code: [%s], Message: [%s]",
+ FAILED_TO_VERIFY_DESTINATION,
+ destinationName,
+ errorOrigin,
+ errorInternalCode,
+ errorMessage);
+ throw new DestinationAccessException(detailedErrorMessage);
+ }
+ }
+
+ @Nonnull
+ private static Supplier prepareSupplier( final HttpClient httpClient, final HttpHead headRequest )
+ {
+ return () -> {
+ try {
+ return httpClient.execute(headRequest);
+ }
+ catch( final IOException e ) {
+ throw new DestinationAccessException(FAILED_TO_VERIFY_DESTINATION, e);
+ }
+ };
+ }
+
+ @Nonnull
+ static ResilienceConfiguration createResilienceConfiguration(
+ @Nonnull final String identifier,
+ @Nonnull final ResilienceConfiguration.TimeLimiterConfiguration timeLimiterConfiguration )
+ {
+ return ResilienceConfiguration
+ .of(TransparentProxy.class + identifier)
+ .timeLimiterConfiguration(timeLimiterConfiguration);
+ }
+
+ private static String getHeaderValue( @Nonnull final HttpMessage message, @Nonnull final String headerName )
+ {
+ if( message.containsHeader(headerName) ) {
+ return message.getFirstHeader(headerName).getValue();
+ }
+ return "";
+ }
+
+ @Nonnull
+ private static
+ TransparentProxyDestination
+ prepareDestination( @Nonnull final String destinationName, @Nonnull final DestinationOptions options )
+ {
+ final Tenant tenant = retrieveTenant();
+
+ final TransparentProxyDestination.GatewayBuilder gatewayBuilder =
+ TransparentProxyDestination.gateway(destinationName, uri);
+ gatewayBuilder.tenantId(tenant.getTenantId());
+
+ final Option fragmentNameOption =
+ DestinationServiceOptionsAugmenter.getFragmentName(options).peek(gatewayBuilder::fragmentName);
+ final Option crossLevelScope =
+ DestinationServiceOptionsAugmenter.getCrossLevelScope(options);
+ if( fragmentNameOption.isDefined() ) {
+ crossLevelScope.peek(gatewayBuilder::fragmentLevel);
+ }
+ crossLevelScope.peek(gatewayBuilder::destinationLevel);
+ DestinationServiceOptionsAugmenter.getAdditionalHeaders(options).forEach(gatewayBuilder::header);
+
+ return gatewayBuilder.build();
+ }
+
+ private static Tenant retrieveTenant()
+ {
+ return TenantAccessor.tryGetCurrentTenant().orElse(() -> {
+ if( providerTenantId == null ) {
+ return Try.failure(new TenantAccessException(NO_TENANT_PROVIDED_ERROR_MESSAGE));
+ }
+ return Try.success(new DefaultTenant(providerTenantId));
+ }).getOrElseThrow(e -> new TenantAccessException(NO_TENANT_PROVIDED_ERROR_MESSAGE, e));
+ }
+
+ @Nonnull
+ @Override
+ public Try tryGetDestination( @Nonnull final String destinationName )
+ {
+ return tryGetDestination(destinationName, DestinationOptions.builder().build());
+ }
+
+ @Nonnull
+ @Override
+ public
+ Try
+ tryGetDestination( @Nonnull final String destinationName, @Nonnull final DestinationOptions options )
+ {
+ final TransparentProxyDestination destination = prepareDestination(destinationName, options);
+ return Try.of(() -> verifyDestination(destination));
+ }
+}
diff --git a/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestination.java b/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestination.java
index 0892eaf15..085c6829c 100644
--- a/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestination.java
+++ b/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestination.java
@@ -56,13 +56,10 @@ public class TransparentProxyDestination implements HttpDestination
static final String CHAIN_VAR_SAML_PROVIDER_DESTINATION_NAME_HEADER_KEY = "x-chain-var-samlProviderDestinationName";
static final String TENANT_ID_AND_TENANT_SUBDOMAIN_BOTH_PASSED_ERROR_MESSAGE =
"Tenant id and tenant subdomain cannot be passed at the same time.";
-
- @Delegate
- private final DestinationProperties baseProperties;
-
@Nonnull
final ImmutableList customHeaders;
-
+ @Delegate
+ private final DestinationProperties baseProperties;
@Nonnull
@Getter( AccessLevel.PACKAGE )
private final ImmutableList customHeaderProviders;
@@ -91,6 +88,41 @@ private TransparentProxyDestination(
}
+ static boolean containsHeader( final Collection headers, final String headerName )
+ {
+ return headers.stream().anyMatch(h -> h.getName().equalsIgnoreCase(headerName));
+ }
+
+ /**
+ * Creates a new builder for a destination.
+ *
+ * A destination connects directly to a specified URL and does not use the destination-gateway. It allows setting
+ * generic headers but does not support gateway-specific properties like destination name or fragments.
+ *
+ * @return A new {@link Builder} instance.
+ */
+ @Nonnull
+ public static Builder destination( @Nonnull final String uri )
+ {
+ return new Builder(uri);
+ }
+
+ /**
+ * Creates a new builder for a destination-gateway.
+ *
+ * A destination-gateway requires a destination name and will be routed through the central destination-gateway. It
+ * supports all gateway-specific properties like fragments, tenant context, and authentication flows.
+ *
+ * @param destinationName
+ * The name of the destination to be resolved by the gateway.
+ * @return A new {@link GatewayBuilder} instance.
+ */
+ @Nonnull
+ public static GatewayBuilder gateway( @Nonnull final String destinationName, @Nonnull final String uri )
+ {
+ return new GatewayBuilder(destinationName, uri);
+ }
+
@Nonnull
@Override
public URI getUri()
@@ -131,11 +163,6 @@ public Collection getHeaders( @Nonnull final URI requestUri )
return allHeaders;
}
- static boolean containsHeader( final Collection headers, final String headerName )
- {
- return headers.stream().anyMatch(h -> h.getName().equalsIgnoreCase(headerName));
- }
-
@Nonnull
@Override
public Option getTlsVersion()
@@ -229,36 +256,6 @@ public int hashCode()
return new HashCodeBuilder(17, 37).append(baseProperties).append(customHeaders).toHashCode();
}
- /**
- * Creates a new builder for a destination.
- *
- * A destination connects directly to a specified URL and does not use the destination-gateway. It allows setting
- * generic headers but does not support gateway-specific properties like destination name or fragments.
- *
- * @return A new {@link Builder} instance.
- */
- @Nonnull
- public static Builder destination( @Nonnull final String uri )
- {
- return new Builder(uri);
- }
-
- /**
- * Creates a new builder for a destination-gateway.
- *
- * A destination-gateway requires a destination name and will be routed through the central destination-gateway. It
- * supports all gateway-specific properties like fragments, tenant context, and authentication flows.
- *
- * @param destinationName
- * The name of the destination to be resolved by the gateway.
- * @return A new {@link GatewayBuilder} instance.
- */
- @Nonnull
- public static GatewayBuilder gateway( @Nonnull final String destinationName, @Nonnull final String uri )
- {
- return new GatewayBuilder(destinationName, uri);
- }
-
/**
* Abstract base class for builders to share common functionality like adding headers and properties.
*
@@ -689,7 +686,7 @@ public GatewayBuilder fragmentName( @Nonnull final String fragmentName )
public GatewayBuilder destinationLevel(
@Nonnull final DestinationServiceOptionsAugmenter.CrossLevelScope destinationLevel )
{
- return header(new Header(DESTINATION_LEVEL_HEADER_KEY, destinationLevel.toString()));
+ return header(new Header(DESTINATION_LEVEL_HEADER_KEY, destinationLevel.toString().toLowerCase()));
}
/**
@@ -704,7 +701,7 @@ public GatewayBuilder destinationLevel(
public GatewayBuilder fragmentLevel(
@Nonnull final DestinationServiceOptionsAugmenter.CrossLevelScope fragmentLevel )
{
- return header(new Header(FRAGMENT_LEVEL_HEADER_KEY, fragmentLevel.toString()));
+ return header(new Header(FRAGMENT_LEVEL_HEADER_KEY, fragmentLevel.toString().toLowerCase()));
}
/**
diff --git a/cloudplatform/connectivity-destination-service/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestinationTest.java b/cloudplatform/connectivity-destination-service/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestinationTest.java
index 77408a4d5..1c95d2eaa 100644
--- a/cloudplatform/connectivity-destination-service/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestinationTest.java
+++ b/cloudplatform/connectivity-destination-service/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestinationTest.java
@@ -86,11 +86,11 @@ void testGatewayHeaders()
new Header(TransparentProxyDestination.DESTINATION_NAME_HEADER_KEY, TEST_DEST_NAME),
new Header(
TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY,
- DestinationServiceOptionsAugmenter.CrossLevelScope.SUBACCOUNT.toString()),
+ DestinationServiceOptionsAugmenter.CrossLevelScope.SUBACCOUNT.toString().toLowerCase()),
new Header(TransparentProxyDestination.FRAGMENT_NAME_HEADER_KEY, "fragName"),
new Header(
TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY,
- DestinationServiceOptionsAugmenter.CrossLevelScope.PROVIDER_SUBACCOUNT.toString()),
+ DestinationServiceOptionsAugmenter.CrossLevelScope.PROVIDER_SUBACCOUNT.toString().toLowerCase()),
new Header(TransparentProxyDestination.FRAGMENT_OPTIONAL_HEADER_KEY, "true"));
}
diff --git a/cloudplatform/connectivity-destination-service/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyTest.java b/cloudplatform/connectivity-destination-service/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyTest.java
new file mode 100644
index 000000000..b22b2c93e
--- /dev/null
+++ b/cloudplatform/connectivity-destination-service/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyTest.java
@@ -0,0 +1,1259 @@
+package com.sap.cloud.sdk.cloudplatform.connectivity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+
+import org.apache.http.HttpStatus;
+import org.apache.http.HttpVersion;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpHead;
+import org.apache.http.message.BasicHttpResponse;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationNotFoundException;
+import com.sap.cloud.sdk.cloudplatform.tenant.DefaultTenant;
+import com.sap.cloud.sdk.cloudplatform.tenant.Tenant;
+import com.sap.cloud.sdk.cloudplatform.tenant.TenantAccessor;
+
+import io.vavr.control.Try;
+
+class TransparentProxyTest
+{
+ private final String expectedErrorMessage = "Destination not found: non-existent-destination";
+
+ private TransparentProxy loader;
+ private NetworkVerifier mockNetworkVerifier;
+
+ @BeforeEach
+ void setUp()
+ {
+ loader = new TransparentProxy();
+ mockNetworkVerifier = mock(NetworkVerifier.class);
+ TransparentProxy.networkVerifier = mockNetworkVerifier;
+ }
+
+ @AfterEach
+ void resetLoader()
+ {
+ TransparentProxy.uri = null;
+ TransparentProxy.providerTenantId = null;
+ HttpClientAccessor.setHttpClientFactory(null);
+ }
+
+ private T executeWithTenant( java.util.concurrent.Callable callable )
+ throws Exception
+ {
+ Tenant tenant = new DefaultTenant("tenant-id", "");
+ return TenantAccessor.executeWithTenant(tenant, callable);
+ }
+
+ // ========== Tests for register(String host) method ==========
+
+ @Test
+ void testRegisterWithLocalhostHost()
+ throws Exception
+ {
+ // Test with localhost which should always be reachable
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ TransparentProxy.register("127.0.0.1", "tenant-id");
+
+ // Verify the stored URI has the http scheme
+ Try result = loader.tryGetDestination("test-destination");
+ assertThat(result.isSuccess()).isTrue();
+
+ Destination destination = result.get();
+ assertThat(destination.asHttp().getUri().toString()).startsWith("http://127.0.0.1");
+ }
+
+ @Test
+ void testRegisterWithHostWithoutScheme()
+ throws Exception
+ {
+ // Test that http:// is automatically added to host without scheme
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ TransparentProxy.register("gateway", "tenant-id");
+
+ Try result = loader.tryGetDestination("test-destination");
+ assertThat(result.isSuccess()).isTrue();
+
+ Destination destination = result.get();
+ assertThat(destination.asHttp().getUri().toString()).startsWith("http://gateway");
+ }
+
+ @Test
+ void testRegisterWithHostWithHttpScheme()
+ throws Exception
+ {
+ // Test that existing http:// scheme is preserved
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ TransparentProxy.register("http://gateway", "tenant-id");
+
+ Try result = loader.tryGetDestination("test-destination");
+ assertThat(result.isSuccess()).isTrue();
+
+ Destination destination = result.get();
+ assertThat(destination.asHttp().getUri().toString()).startsWith("http://gateway");
+ }
+
+ @Test
+ void testRegisterWithHostWithHttpsScheme()
+ throws Exception
+ {
+ // Test that existing https:// scheme is preserved
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ TransparentProxy.register("https://gateway", "tenant-id");
+
+ Try result = loader.tryGetDestination("test-destination");
+ assertThat(result.isSuccess()).isTrue();
+
+ Destination destination = result.get();
+ assertThat(destination.asHttp().getUri().toString()).startsWith("https://gateway");
+ }
+
+ // ========== Tests for register(String host, Integer port) method ==========
+
+ @Test
+ void testRegisterWithUnknownHostAndPort()
+ {
+ doThrow(new DestinationAccessException("Host 'unknown-host' could not be resolved"))
+ .when(mockNetworkVerifier)
+ .verifyHostConnectivity("unknown-host", 8080);
+
+ final DestinationAccessException exception =
+ assertThrows(DestinationAccessException.class, () -> TransparentProxy.register("unknown-host", 8080));
+
+ assertThat(exception.getMessage()).contains("could not be resolved");
+ }
+
+ @Test
+ void testRegisterWithUnreachablePort()
+ {
+ doThrow(new DestinationAccessException("Host 'gateway' on port 65432 is not reachable"))
+ .when(mockNetworkVerifier)
+ .verifyHostConnectivity("gateway", 65432);
+
+ final DestinationAccessException exception =
+ assertThrows(DestinationAccessException.class, () -> TransparentProxy.register("gateway", 65432));
+
+ assertThat(exception.getMessage()).contains("is not reachable");
+ }
+
+ // ========== Tests for register methods with providerTenantId ==========
+
+ @Test
+ void testRegisterWithHostPortAndProviderTenantId()
+ throws IOException
+ {
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ TransparentProxy.register("gateway", 8080, "provider-tenant-123");
+
+ Try result = loader.tryGetDestination("test-destination");
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.get()).isNotNull();
+ }
+
+ @Test
+ void testTenantIdFromOptionsOverridesProviderTenantId()
+ throws IOException
+ {
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ // Register with provider tenant ID
+ TransparentProxy.register("gateway", 8080, "provider-tenant-fallback");
+
+ // Pass a different tenant ID in options using custom headers - this should take precedence
+ DestinationOptions options =
+ DestinationOptions
+ .builder()
+ .augmentBuilder(
+ DestinationServiceOptionsAugmenter
+ .augmenter()
+ .customHeaders(
+ new Header(TransparentProxyDestination.TENANT_ID_HEADER_KEY, "options-tenant-123")))
+ .build();
+
+ Try result = loader.tryGetDestination("test-destination", options);
+ assertThat(result.isSuccess()).isTrue();
+
+ // Verify that the tenant ID from options is used
+ HttpDestination destination = result.get().asHttp();
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(
+ h -> TransparentProxyDestination.TENANT_ID_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "options-tenant-123".equals(h.getValue()));
+ }
+
+ @Test
+ void testContextTenantPreventsFallbackToProviderTenantId()
+ throws IOException
+ {
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ // Register with provider tenant ID
+ TransparentProxy.register("gateway", 8080, "provider-tenant-fallback");
+
+ // Execute with a tenant in context - providerTenantId should NOT be used
+ Tenant contextTenant = new DefaultTenant("context-tenant-id", "context-tenant-name");
+
+ Try result =
+ TenantAccessor.executeWithTenant(contextTenant, () -> loader.tryGetDestination("test-destination"));
+
+ assertThat(result.isSuccess()).isTrue();
+
+ HttpDestination destination = result.get().asHttp();
+
+ // When tenant is available in context, providerTenantId should NOT be used
+ // The tenant from context takes precedence, so providerTenantId is ignored
+ assertThat(destination.getHeaders(destination.getUri()))
+ .noneMatch(
+ h -> TransparentProxyDestination.TENANT_ID_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "provider-tenant-fallback".equals(h.getValue()));
+ }
+
+ @Test
+ void testRegisterWithHostAndProviderTenantId()
+ throws IOException
+ {
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ TransparentProxy.register("gateway", "provider-tenant-456");
+
+ Try result = loader.tryGetDestination("test-destination");
+ assertThat(result.isSuccess()).isTrue();
+
+ Destination destination = result.get();
+ assertThat(destination.asHttp().getUri().toString()).startsWith("http://gateway:80");
+ assertThat(TransparentProxy.providerTenantId).isEqualTo("provider-tenant-456");
+ }
+
+ @Test
+ void testRegisterWithHostPortProviderTenantIdAndScheme()
+ throws IOException
+ {
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ TransparentProxy.register("https://gateway", 443, "provider-tenant-789");
+
+ Try result = loader.tryGetDestination("test-destination");
+ assertThat(result.isSuccess()).isTrue();
+
+ Destination destination = result.get();
+ assertThat(destination.asHttp().getUri().toString()).startsWith("https://gateway:443");
+ assertThat(TransparentProxy.providerTenantId).isEqualTo("provider-tenant-789");
+ }
+
+ @Test
+ void testRegisterWithProviderTenantIdStoresCorrectly()
+ throws IOException
+ {
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ // Test with 3-parameter version
+ TransparentProxy.register("localhost", 8080, "tenant-abc");
+ assertThat(TransparentProxy.providerTenantId).isEqualTo("tenant-abc");
+
+ // Reset and test with 2-parameter version
+ TransparentProxy.uri = null;
+ TransparentProxy.providerTenantId = null;
+
+ TransparentProxy.register("localhost", "tenant-xyz");
+ assertThat(TransparentProxy.providerTenantId).isEqualTo("tenant-xyz");
+ }
+
+ @Test
+ void testRegisterWithProviderTenantIdFailsOnHostWithPath()
+ {
+ final DestinationAccessException exception =
+ assertThrows(
+ DestinationAccessException.class,
+ () -> TransparentProxy.register("gateway/api", 8080, "provider-tenant"));
+
+ assertThat(exception.getMessage()).contains("contains a path");
+ assertThat(exception.getMessage()).contains("Paths are not allowed");
+ }
+
+ @Test
+ void testRegisterWithProviderTenantIdFailsOnSecondRegistration()
+ throws IOException
+ {
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ TransparentProxy.register("gateway", "provider-tenant-1");
+
+ final DestinationAccessException exception =
+ assertThrows(
+ DestinationAccessException.class,
+ () -> TransparentProxy.register("other-gateway", "provider-tenant-2"));
+
+ assertThat(exception.getMessage())
+ .contains("TransparentProxy is already registered. Only one registration is allowed.");
+ }
+
+ @Test
+ void testTryGetDestinationWithoutOptions()
+ throws Exception
+ {
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ TransparentProxy.register("gateway", "tenant-id");
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("test-destination"));
+
+ assertThat(result.isSuccess()).isTrue();
+
+ Destination destination = result.get();
+ assertThat(destination).isNotNull();
+ assertThat(destination.asHttp().getUri().toString()).startsWith("http://gateway");
+ }
+
+ @Test
+ void testTryGetDestinationWithOptions()
+ throws Exception
+ {
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ TransparentProxy.register("gateway", "tenant-id");
+
+ Try result =
+ executeWithTenant(
+ () -> loader.tryGetDestination("test-destination-with-options", DestinationOptions.builder().build()));
+
+ assertThat(result.isSuccess()).isTrue();
+
+ Destination destination = result.get();
+ assertThat(destination).isNotNull();
+ assertThat(destination.asHttp().getUri().toString()).startsWith("http://gateway");
+ }
+
+ @Test
+ void testTryGetDestinationReturnsSuccessfulTry()
+ throws Exception
+ {
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ TransparentProxy.register("https://gateway", "tenant-id");
+
+ Try result1 = executeWithTenant(() -> loader.tryGetDestination("destination1"));
+ Try result2 =
+ executeWithTenant(() -> loader.tryGetDestination("destination2", DestinationOptions.builder().build()));
+
+ assertThat(result1.isSuccess()).isTrue();
+ assertThat(result2.isSuccess()).isTrue();
+ assertThat(result1.isFailure()).isFalse();
+ assertThat(result2.isFailure()).isFalse();
+ }
+
+ // ========== Tests for edge cases and error handling ==========
+
+ @Test
+ void testRegisterWithHostContainingPathFails()
+ {
+ final DestinationAccessException exception =
+ assertThrows(DestinationAccessException.class, () -> TransparentProxy.register("gateway/some/path"));
+
+ assertThat(exception.getMessage()).contains("contains a path");
+ assertThat(exception.getMessage()).contains("Paths are not allowed");
+ }
+
+ @Test
+ void testRegisterWithComplexUriContainingPathFails()
+ {
+ final DestinationAccessException exception =
+ assertThrows(
+ DestinationAccessException.class,
+ () -> TransparentProxy.register("https://gateway:443/api/v1"));
+
+ assertThat(exception.getMessage()).contains("contains a path");
+ assertThat(exception.getMessage()).contains("/api/v1");
+ assertThat(exception.getMessage()).contains("Paths are not allowed");
+ }
+
+ @Test
+ void testMultipleRegistrationsThrowsException()
+ throws Exception
+ {
+ // Test that first registration succeeds
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ TransparentProxy.register("gateway", "tenant-id");
+
+ Try result1 = executeWithTenant(() -> loader.tryGetDestination("test1"));
+ assertThat(result1.get().asHttp().getUri().toString()).startsWith("http://gateway");
+
+ // Test that second registration throws exception
+ DestinationAccessException exception =
+ assertThrows(DestinationAccessException.class, () -> TransparentProxy.register("https://gateway"));
+
+ assertThat(exception.getMessage())
+ .contains("TransparentProxy is already registered. Only one registration is allowed.");
+
+ // Verify that the original registration is still active
+ Try result2 = executeWithTenant(() -> loader.tryGetDestination("test2"));
+ assertThat(result2.get().asHttp().getUri().toString()).startsWith("http://gateway");
+ }
+
+ @Test
+ void testDestinationLoaderInterface()
+ throws Exception
+ {
+ // Test that TransparentProxyLoader properly implements DestinationLoader
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ assertThat(loader).isInstanceOf(DestinationLoader.class);
+
+ // Setup: register a host first
+ TransparentProxy.register("gateway", "tenant-id");
+
+ // Test interface methods
+ DestinationLoader destinationLoader = loader;
+
+ Try result1 = executeWithTenant(() -> destinationLoader.tryGetDestination("test"));
+ Try result2 =
+ executeWithTenant(() -> destinationLoader.tryGetDestination("test", DestinationOptions.builder().build()));
+
+ assertThat(result1.isSuccess()).isTrue();
+ assertThat(result2.isSuccess()).isTrue();
+ }
+
+ @Test
+ void testRegisterWithDifferentSchemes()
+ throws Exception
+ {
+ // Test various schemes to ensure they are preserved
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ String[] schemes = { "http://", "https://", "ftp://", "custom://" };
+ String hostname = "gateway";
+
+ for( String scheme : schemes ) {
+ // Reset registration state between tests
+ TransparentProxy.uri = null;
+ TransparentProxy.providerTenantId = null;
+
+ // Register with the current scheme
+ TransparentProxy.register(scheme + hostname, "tenant-id");
+
+ // Verify the scheme is preserved in the destination
+ Try result = executeWithTenant(() -> loader.tryGetDestination("test-destination"));
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.get().asHttp().getUri().toString()).startsWith(scheme + hostname);
+ }
+ }
+
+ @Test
+ void testRegisterWithPortInUriButNoPath()
+ throws Exception
+ {
+ TransparentProxy.register("https://gateway:9443", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("test-destination"));
+ assertThat(result.isSuccess()).isTrue();
+
+ Destination destination = result.get();
+ assertThat(destination.asHttp().getUri().toString()).startsWith("https://gateway:9443");
+ }
+
+ @Test
+ void testRegisterWithPortInUriAndPathFails()
+ {
+ final DestinationAccessException exception =
+ assertThrows(DestinationAccessException.class, () -> TransparentProxy.register("https://gateway:9443/api"));
+
+ assertThat(exception.getMessage()).contains("contains a path");
+ assertThat(exception.getMessage()).contains("/api");
+ assertThat(exception.getMessage()).contains("Paths are not allowed");
+ }
+
+ @Test
+ void testRegisterWithHostAndPort()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", 8080, "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("test-destination"));
+ assertThat(result.isSuccess()).isTrue();
+
+ Destination destination = result.get();
+ assertThat(destination.asHttp().getUri().toString()).startsWith("http://gateway:8080");
+ }
+
+ @Test
+ void testRegisterWithHostAndPortWithScheme()
+ throws Exception
+ {
+ TransparentProxy.register("https://gateway", 443, "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("test-destination"));
+ assertThat(result.isSuccess()).isTrue();
+
+ Destination destination = result.get();
+ assertThat(destination.asHttp().getUri().toString()).startsWith("https://gateway:443");
+ }
+
+ // ========== Comprehensive Path Validation Tests ==========
+
+ @Test
+ void testRegisterWithRootPathIsNotAllowed()
+ {
+ // Test that root path "/" is rejected
+ final DestinationAccessException exception =
+ assertThrows(DestinationAccessException.class, () -> TransparentProxy.register("https://gateway/"));
+
+ assertThat(exception.getMessage()).contains("contains a path");
+ assertThat(exception.getMessage()).contains("/");
+ assertThat(exception.getMessage()).contains("Paths are not allowed");
+ }
+
+ @Test
+ void testRegisterWithVariousPathsFails()
+ {
+ // Test various path patterns that should all fail
+ String[] hostsWithPaths =
+ {
+ "gateway/api",
+ "gateway/api/v1",
+ "gateway/path/to/resource",
+ "http://gateway/api",
+ "https://gateway/api/v1",
+ "gateway:8080/api",
+ "https://gateway:443/api/v1",
+ "gateway/api?query=param",
+ "gateway/api#fragment" };
+
+ for( String hostWithPath : hostsWithPaths ) {
+ // Reset registration state between tests
+ resetLoader();
+
+ DestinationAccessException exception =
+ assertThrows(
+ DestinationAccessException.class,
+ () -> TransparentProxy.register(hostWithPath),
+ "Expected path validation to fail for: " + hostWithPath);
+
+ assertThat(exception.getMessage())
+ .as("Error message should mention path for: " + hostWithPath)
+ .contains("contains a path");
+ assertThat(exception.getMessage())
+ .as("Error message should mention paths not allowed for: " + hostWithPath)
+ .contains("Paths are not allowed");
+ }
+ }
+
+ // ========== Tests for isDestinationNotFound - Destination Not Found Cases ==========
+
+ @Test
+ void testDestinationNotFoundWhenStatus502WithErrorCode404()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response =
+ new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_BAD_GATEWAY, "Bad Gateway");
+ response.setHeader("x-error-internal-code", "404");
+ response.setHeader("x-error-message", expectedErrorMessage);
+
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("non-existent-destination"));
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.getCause()).isInstanceOf(DestinationNotFoundException.class);
+ assertThat(result.getCause().getMessage()).contains(expectedErrorMessage);
+ }
+
+ @Test
+ void testDestinationNotFoundWhenStatus502WithErrorCode404WithOptions()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response =
+ new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_BAD_GATEWAY, "Bad Gateway");
+ response.setHeader("x-error-internal-code", "404");
+ response.setHeader("x-error-message", expectedErrorMessage);
+
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ Try result =
+ executeWithTenant(
+ () -> loader.tryGetDestination("non-existent-destination", DestinationOptions.builder().build()));
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.getCause()).isInstanceOf(DestinationNotFoundException.class);
+ assertThat(result.getCause().getMessage()).contains(expectedErrorMessage);
+ }
+
+ @Test
+ void testDestinationFoundWhenStatus200()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("existing-destination"));
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.get()).isNotNull();
+ }
+
+ @Test
+ void testDestinationFoundWhenStatus502WithoutErrorCode()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response =
+ new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_BAD_GATEWAY, "Bad Gateway");
+
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("destination-with-502"));
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.get()).isNotNull();
+ }
+
+ @Test
+ void testDestinationFoundWhenStatus502WithDifferentErrorCode()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response =
+ new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_BAD_GATEWAY, "Bad Gateway");
+ response.setHeader("x-error-internal-code", "500");
+
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("destination-with-different-error"));
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.get()).isNotNull();
+ }
+
+ @Test
+ void testRegisterWithValidHostsWithoutPathsSucceeds()
+ throws Exception
+ {
+ String[] validHosts =
+ {
+ "gateway",
+ "gateway.example.com",
+ "gateway.svc.cluster.local",
+ "http://gateway",
+ "https://gateway",
+ "gateway:8080",
+ "gateway:443",
+ "http://gateway:8080",
+ "https://gateway:443",
+ "127.0.0.1",
+ "192.168.1.1:8080",
+ "localhost" };
+
+ for( String validHost : validHosts ) {
+ // Reset registration state between tests
+ resetLoader();
+
+ // This should not throw an exception
+ TransparentProxy.register(validHost, "tenant-id");
+
+ // Set up mock after registration
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ // Verify the registration worked
+ Try result = executeWithTenant(() -> loader.tryGetDestination("test-destination"));
+ assertThat(result.isSuccess()).as("Registration should succeed for valid host: " + validHost).isTrue();
+ }
+ }
+
+ @Test
+ void testTryGetDestinationWithLevelOptions()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ DestinationOptions options =
+ DestinationOptions
+ .builder()
+ .augmentBuilder(
+ DestinationServiceOptionsAugmenter
+ .augmenter()
+ .crossLevelConsumption(DestinationServiceOptionsAugmenter.CrossLevelScope.SUBACCOUNT))
+ .build();
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("dest-with-levels", options));
+
+ assertThat(result.isSuccess()).isTrue();
+ HttpDestination destination = result.get().asHttp();
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(
+ h -> TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "subaccount".equals(h.getValue()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .noneMatch(
+ h -> TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "subaccount".equals(h.getValue()));
+ }
+
+ @Test
+ void testTryGetDestinationWithAllSupportedOptions()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ DestinationOptions options =
+ DestinationOptions
+ .builder()
+ .augmentBuilder(
+ DestinationServiceOptionsAugmenter
+ .augmenter()
+ .fragmentName("complete-fragment")
+ .customHeaders(
+ new Header(TransparentProxyDestination.FRAGMENT_OPTIONAL_HEADER_KEY, "false"),
+ new Header(TransparentProxyDestination.TENANT_ID_HEADER_KEY, "complete-tenant"),
+ new Header(TransparentProxyDestination.CLIENT_ASSERTION_HEADER_KEY, "complete-assertion"),
+ new Header(TransparentProxyDestination.AUTHORIZATION_HEADER_KEY, "Bearer complete-token")))
+ .build();
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("dest-with-all", options));
+
+ assertThat(result.isSuccess()).isTrue();
+ HttpDestination destination = result.get().asHttp();
+
+ // Verify all key headers are present
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(h -> TransparentProxyDestination.FRAGMENT_NAME_HEADER_KEY.equalsIgnoreCase(h.getName()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(h -> TransparentProxyDestination.FRAGMENT_OPTIONAL_HEADER_KEY.equalsIgnoreCase(h.getName()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(h -> TransparentProxyDestination.TENANT_ID_HEADER_KEY.equalsIgnoreCase(h.getName()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(h -> TransparentProxyDestination.CLIENT_ASSERTION_HEADER_KEY.equalsIgnoreCase(h.getName()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(h -> TransparentProxyDestination.AUTHORIZATION_HEADER_KEY.equalsIgnoreCase(h.getName()));
+ }
+
+ // ========== Unit Tests for Augmented Options Scenarios ==========
+
+ @Test
+ void testFragmentNameWithAugmentedOptions()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ DestinationOptions options =
+ DestinationOptions
+ .builder()
+ .augmentBuilder(DestinationServiceOptionsAugmenter.augmenter().fragmentName("test-fragment"))
+ .build();
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("dest-with-fragment", options));
+
+ assertThat(result.isSuccess()).isTrue();
+ HttpDestination destination = result.get().asHttp();
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(
+ h -> TransparentProxyDestination.FRAGMENT_NAME_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "test-fragment".equals(h.getValue()));
+ }
+
+ @Test
+ void testCrossLevelConsumptionSubaccount()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ DestinationOptions options =
+ DestinationOptions
+ .builder()
+ .augmentBuilder(
+ DestinationServiceOptionsAugmenter
+ .augmenter()
+ .crossLevelConsumption(DestinationServiceOptionsAugmenter.CrossLevelScope.SUBACCOUNT))
+ .build();
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("dest-subaccount", options));
+
+ assertThat(result.isSuccess()).isTrue();
+ HttpDestination destination = result.get().asHttp();
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(
+ h -> TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "subaccount".equals(h.getValue()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .noneMatch(
+ h -> TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "subaccount".equals(h.getValue()));
+ }
+
+ @Test
+ void testCrossLevelConsumptionProviderSubaccount()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ DestinationOptions options =
+ DestinationOptions
+ .builder()
+ .augmentBuilder(
+ DestinationServiceOptionsAugmenter
+ .augmenter()
+ .crossLevelConsumption(DestinationServiceOptionsAugmenter.CrossLevelScope.PROVIDER_SUBACCOUNT))
+ .build();
+
+ Try result =
+ executeWithTenant(() -> loader.tryGetDestination("dest-provider-subaccount", options));
+
+ assertThat(result.isSuccess()).isTrue();
+ HttpDestination destination = result.get().asHttp();
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(
+ h -> TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "provider_subaccount".equals(h.getValue()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .noneMatch(
+ h -> TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "provider_subaccount".equals(h.getValue()));
+ }
+
+ @Test
+ void testCrossLevelConsumptionInstance()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ DestinationOptions options =
+ DestinationOptions
+ .builder()
+ .augmentBuilder(
+ DestinationServiceOptionsAugmenter
+ .augmenter()
+ .crossLevelConsumption(DestinationServiceOptionsAugmenter.CrossLevelScope.INSTANCE))
+ .build();
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("dest-instance", options));
+
+ assertThat(result.isSuccess()).isTrue();
+ HttpDestination destination = result.get().asHttp();
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(
+ h -> TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "instance".equals(h.getValue()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .noneMatch(
+ h -> TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "instance".equals(h.getValue()));
+ }
+
+ @Test
+ void testCrossLevelConsumptionProviderInstance()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ DestinationOptions options =
+ DestinationOptions
+ .builder()
+ .augmentBuilder(
+ DestinationServiceOptionsAugmenter
+ .augmenter()
+ .crossLevelConsumption(DestinationServiceOptionsAugmenter.CrossLevelScope.PROVIDER_INSTANCE))
+ .build();
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("dest-provider-instance", options));
+
+ assertThat(result.isSuccess()).isTrue();
+ HttpDestination destination = result.get().asHttp();
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(
+ h -> TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "provider_instance".equals(h.getValue()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .noneMatch(
+ h -> TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "provider_instance".equals(h.getValue()));
+ }
+
+ @Test
+ void testCustomHeadersWithAugmentedOptions()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ DestinationOptions options =
+ DestinationOptions
+ .builder()
+ .augmentBuilder(
+ DestinationServiceOptionsAugmenter
+ .augmenter()
+ .customHeaders(
+ new Header("X-Custom-Header-1", "value1"),
+ new Header("X-Custom-Header-2", "value2")))
+ .build();
+
+ Try result =
+ executeWithTenant(() -> loader.tryGetDestination("dest-with-custom-headers", options));
+
+ assertThat(result.isSuccess()).isTrue();
+ HttpDestination destination = result.get().asHttp();
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(h -> "X-Custom-Header-1".equalsIgnoreCase(h.getName()) && "value1".equals(h.getValue()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(h -> "X-Custom-Header-2".equalsIgnoreCase(h.getName()) && "value2".equals(h.getValue()));
+ }
+
+ @Test
+ void testCombinedFragmentAndCrossLevelOptions()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ DestinationOptions options =
+ DestinationOptions
+ .builder()
+ .augmentBuilder(
+ DestinationServiceOptionsAugmenter
+ .augmenter()
+ .fragmentName("combined-fragment")
+ .crossLevelConsumption(DestinationServiceOptionsAugmenter.CrossLevelScope.INSTANCE))
+ .build();
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("dest-combined", options));
+
+ assertThat(result.isSuccess()).isTrue();
+ HttpDestination destination = result.get().asHttp();
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(
+ h -> TransparentProxyDestination.FRAGMENT_NAME_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "combined-fragment".equals(h.getValue()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(
+ h -> TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "instance".equals(h.getValue()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(
+ h -> TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "instance".equals(h.getValue()));
+ }
+
+ @Test
+ void testFragmentWithCustomHeadersAndCrossLevel()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ DestinationOptions options =
+ DestinationOptions
+ .builder()
+ .augmentBuilder(
+ DestinationServiceOptionsAugmenter
+ .augmenter()
+ .fragmentName("full-fragment")
+ .crossLevelConsumption(DestinationServiceOptionsAugmenter.CrossLevelScope.PROVIDER_INSTANCE)
+ .customHeaders(
+ new Header("X-Test-Header", "test-value"),
+ new Header(TransparentProxyDestination.FRAGMENT_OPTIONAL_HEADER_KEY, "true")))
+ .build();
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("dest-full-options", options));
+
+ assertThat(result.isSuccess()).isTrue();
+ HttpDestination destination = result.get().asHttp();
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(
+ h -> TransparentProxyDestination.FRAGMENT_NAME_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "full-fragment".equals(h.getValue()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(
+ h -> TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "provider_instance".equals(h.getValue()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(
+ h -> TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "provider_instance".equals(h.getValue()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(h -> "X-Test-Header".equalsIgnoreCase(h.getName()) && "test-value".equals(h.getValue()));
+ assertThat(destination.getHeaders(destination.getUri()))
+ .anyMatch(
+ h -> TransparentProxyDestination.FRAGMENT_OPTIONAL_HEADER_KEY.equalsIgnoreCase(h.getName())
+ && "true".equals(h.getValue()));
+ }
+
+ @Test
+ void testRetrievalStrategyWithAugmentedOptions()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ DestinationOptions options =
+ DestinationOptions
+ .builder()
+ .augmentBuilder(
+ DestinationServiceOptionsAugmenter
+ .augmenter()
+ .retrievalStrategy(DestinationServiceRetrievalStrategy.ALWAYS_PROVIDER))
+ .build();
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("dest-with-strategy", options));
+
+ assertThat(result.isSuccess()).isTrue();
+ // Verify the strategy is stored in options
+ assertThat(DestinationServiceOptionsAugmenter.getRetrievalStrategy(options))
+ .isNotEmpty()
+ .contains(DestinationServiceRetrievalStrategy.ALWAYS_PROVIDER);
+ }
+
+ @Test
+ void testTokenExchangeStrategyWithAugmentedOptions()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ DestinationOptions options =
+ DestinationOptions
+ .builder()
+ .augmentBuilder(
+ DestinationServiceOptionsAugmenter
+ .augmenter()
+ .tokenExchangeStrategy(DestinationServiceTokenExchangeStrategy.FORWARD_USER_TOKEN))
+ .build();
+
+ Try result =
+ executeWithTenant(() -> loader.tryGetDestination("dest-with-token-strategy", options));
+
+ assertThat(result.isSuccess()).isTrue();
+ // Verify the token exchange strategy is stored in options
+ assertThat(DestinationServiceOptionsAugmenter.getTokenExchangeStrategy(options))
+ .isNotEmpty()
+ .contains(DestinationServiceTokenExchangeStrategy.FORWARD_USER_TOKEN);
+ }
+
+ @Test
+ void testRefreshTokenWithAugmentedOptions()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ DestinationOptions options =
+ DestinationOptions
+ .builder()
+ .augmentBuilder(DestinationServiceOptionsAugmenter.augmenter().refreshToken("test-refresh-token"))
+ .build();
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("dest-with-refresh-token", options));
+
+ assertThat(result.isSuccess()).isTrue();
+ // Verify the refresh token is stored in options
+ assertThat(DestinationServiceOptionsAugmenter.getRefreshToken(options))
+ .isNotEmpty()
+ .contains("test-refresh-token");
+ }
+
+ @Test
+ void testEmptyAugmentedOptions()
+ throws Exception
+ {
+ TransparentProxy.register("gateway", "tenant-id");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ DestinationOptions options =
+ DestinationOptions.builder().augmentBuilder(DestinationServiceOptionsAugmenter.augmenter()).build();
+
+ Try result = executeWithTenant(() -> loader.tryGetDestination("dest-empty-augmented", options));
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.get()).isNotNull();
+ }
+
+ @Test
+ void testTenantAccessExceptionWhenTenantMissingInContextAndProviderTenantId()
+ throws IOException
+ {
+ TransparentProxy.register("gateway");
+
+ final HttpClient mockHttpClient = mock(HttpClient.class);
+ final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK");
+ when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response);
+ HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient);
+
+ com.sap.cloud.sdk.cloudplatform.tenant.exception.TenantAccessException exception =
+ assertThrows(
+ com.sap.cloud.sdk.cloudplatform.tenant.exception.TenantAccessException.class,
+ () -> loader.tryGetDestination("test-destination"));
+
+ assertThat(exception.getMessage()).contains("No current tenant defined");
+ assertThat(exception.getMessage()).contains("no provider tenant id configured");
+ }
+
+}
diff --git a/release_notes.md b/release_notes.md
index b0c19875b..699ac878a 100644
--- a/release_notes.md
+++ b/release_notes.md
@@ -4,22 +4,26 @@
### 🚧 Known Issues
--
+-
### 🔧 Compatibility Notes
--
+-
### ✨ New Functionality
-- `DestinationService.tryGetDestination` now checks if the given destination exists before trying to call it directly. This behaviour is enabled by default and can be disabled via `DestinationService.Cache.disablePreLookupCheck`.
+- `DestinationService.tryGetDestination` now checks if the given destination exists before trying to call it directly.
+ This behaviour is enabled by default and can be disabled via `DestinationService.Cache.disablePreLookupCheck`.
+- Added native DestinationLoader implementation through the `TransparentProxy` class. This enables seamless destination
+ retrieval via `tryGetDestination` and provides full Cloud SDK integration for applications running in Kubernetes
+ environments where Transparent Proxy is available.
### 📈 Improvements
--
+-
### 🐛 Fixed Issues
-- Fix unintended modification of `serviceNameMappings.properties` during OData service regeneration altering stored mappings.
+- Fix unintended modification of `serviceNameMappings.properties` during OData service regeneration altering stored
+ mappings.
Additionally, service name cleanup is now case-insensitive for consistency.
-