From bc125256f00c6a7aff328acb3efb337ec823d10d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Tue, 28 Apr 2026 16:22:30 -0300 Subject: [PATCH 01/10] feat: add get_erp_headers to Destination model --- src/sap_cloud_sdk/destination/_models.py | 16 +++++++++++ tests/destination/unit/test_models.py | 34 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/sap_cloud_sdk/destination/_models.py b/src/sap_cloud_sdk/destination/_models.py index 32682442..d8368070 100644 --- a/src/sap_cloud_sdk/destination/_models.py +++ b/src/sap_cloud_sdk/destination/_models.py @@ -366,6 +366,22 @@ def to_dict(self) -> Dict[str, Any]: payload[k] = v return payload + def get_erp_headers(self) -> Dict[str, str]: + """Return SAP ERP-specific headers derived from destination properties. + + Reads sap-client and sap-language from the destination properties and + returns them as HTTP headers. + + Returns: + Dict[str, str]: Headers to inject into requests to the target system. + """ + headers: Dict[str, str] = {} + if "sap-client" in self.properties: + headers["sap-client"] = self.properties["sap-client"] + if "sap-language" in self.properties: + headers["sap-language"] = self.properties["sap-language"] + return headers + @dataclass class AuthToken: diff --git a/tests/destination/unit/test_models.py b/tests/destination/unit/test_models.py index 3e1829c0..0df9fabb 100644 --- a/tests/destination/unit/test_models.py +++ b/tests/destination/unit/test_models.py @@ -240,6 +240,40 @@ def test_destination_from_dict_with_none_type(self): assert "missing required fields" in str(exc_info.value) + def test_get_erp_headers_returns_sap_client(self): + dest = Destination.from_dict({ + "Name": "my-erp", "Type": "HTTP", + "sap-client": "100", + }) + assert dest.get_erp_headers() == {"sap-client": "100"} + + def test_get_erp_headers_returns_sap_language(self): + dest = Destination.from_dict({ + "Name": "my-erp", "Type": "HTTP", + "sap-language": "en", + }) + assert dest.get_erp_headers() == {"sap-language": "en"} + + def test_get_erp_headers_returns_both(self): + dest = Destination.from_dict({ + "Name": "my-erp", "Type": "HTTP", + "sap-client": "100", + "sap-language": "en", + }) + assert dest.get_erp_headers() == {"sap-client": "100", "sap-language": "en"} + + def test_get_erp_headers_returns_empty_when_no_erp_properties(self): + dest = Destination.from_dict({"Name": "my-erp", "Type": "HTTP"}) + assert dest.get_erp_headers() == {} + + def test_get_erp_headers_ignores_other_properties(self): + dest = Destination.from_dict({ + "Name": "my-erp", "Type": "HTTP", + "sap-client": "100", + "some-other-prop": "value", + }) + assert dest.get_erp_headers() == {"sap-client": "100"} + class TestFragmentModel: """Tests for Fragment dataclass.""" From 23315b118d1dc38abc55dc3f61c4a202a6898748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Wed, 13 May 2026 10:31:15 -0300 Subject: [PATCH 02/10] feat: add DestinationHttpClient to simplify calls to target systems --- src/sap_cloud_sdk/destination/__init__.py | 2 + .../destination/_destination_http_client.py | 127 +++++++++++++++++ .../unit/test_destination_http_client.py | 134 ++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 src/sap_cloud_sdk/destination/_destination_http_client.py create mode 100644 tests/destination/unit/test_destination_http_client.py diff --git a/src/sap_cloud_sdk/destination/__init__.py b/src/sap_cloud_sdk/destination/__init__.py index 8ddcefa5..d4e6b7e9 100644 --- a/src/sap_cloud_sdk/destination/__init__.py +++ b/src/sap_cloud_sdk/destination/__init__.py @@ -48,6 +48,7 @@ ) from sap_cloud_sdk.destination.config import load_from_env_or_mount, DestinationConfig from sap_cloud_sdk.destination._http import TokenProvider, DestinationHttp +from sap_cloud_sdk.destination._destination_http_client import DestinationHttpClient from sap_cloud_sdk.destination.client import DestinationClient from sap_cloud_sdk.destination.fragment_client import FragmentClient from sap_cloud_sdk.destination.certificate_client import CertificateClient @@ -235,6 +236,7 @@ def create_certificate_client( "LocalDevDestinationClient", "LocalDevFragmentClient", "LocalDevCertificateClient", + "DestinationHttpClient", # Exceptions "DestinationError", "ClientCreationError", diff --git a/src/sap_cloud_sdk/destination/_destination_http_client.py b/src/sap_cloud_sdk/destination/_destination_http_client.py new file mode 100644 index 00000000..7b0940b9 --- /dev/null +++ b/src/sap_cloud_sdk/destination/_destination_http_client.py @@ -0,0 +1,127 @@ +"""HTTP client for calling the target system described by a Destination.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +import requests +from requests import Response + +from sap_cloud_sdk.destination._models import Destination, DestinationType + + +class DestinationHttpClient: + """Wraps requests.Session to call the target system described by a Destination. + + Pre-bakes SAP ERP headers (sap-client, sap-language) and auth headers from + the destination so callers never have to set them manually. + + Usage:: + + dest = client.get_destination("my-erp") + http = DestinationHttpClient(dest) + response = http.get("/sap/opu/odata/sap/API_BUSINESS_PARTNER") + """ + + def __init__(self, destination: Destination) -> None: + if destination.type not in (DestinationType.HTTP, "HTTP"): + raise ValueError( + f"DestinationHttpClient only supports HTTP destinations, got: {destination.type}" + ) + + self._destination = destination + self._session = requests.Session() + + # Pre-bake sap-client / sap-language — relevant mainly for OnPremise destinations + self._session.headers.update(destination.get_erp_headers()) + + # Pre-bake auth headers — BTP may return multiple tokens, skip empty ones + for token in destination.auth_tokens: + key = token.http_header.get("key") + value = token.http_header.get("value") + if key and value: + self._session.headers[key] = value + + self._base_url = destination.url.rstrip("/") if destination.url else "" + + def request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, + **kwargs: Any, + ) -> Response: + """Send an HTTP request to the target system. + + Args: + method: HTTP verb (GET, POST, PUT, PATCH, DELETE). + path: Path relative to the destination URL. + params: Optional query parameters. + json: Optional JSON body. + headers: Optional additional headers merged on top of pre-baked ones. + **kwargs: Passed through to requests.Session.request. + + Returns: + requests.Response from the target system. + """ + url = f"{self._base_url}/{path.lstrip('/')}" if path else self._base_url + return self._session.request( + method=method.upper(), + url=url, + params=params, + json=json, + headers=headers, + **kwargs, + ) + + def get( + self, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + **kwargs: Any, + ) -> Response: + return self.request("GET", path, params=params, headers=headers, **kwargs) + + def post( + self, + path: str, + *, + json: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, + **kwargs: Any, + ) -> Response: + return self.request("POST", path, json=json, headers=headers, **kwargs) + + def put( + self, + path: str, + *, + json: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, + **kwargs: Any, + ) -> Response: + return self.request("PUT", path, json=json, headers=headers, **kwargs) + + def patch( + self, + path: str, + *, + json: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, + **kwargs: Any, + ) -> Response: + return self.request("PATCH", path, json=json, headers=headers, **kwargs) + + def delete( + self, + path: str, + *, + headers: Optional[Dict[str, str]] = None, + **kwargs: Any, + ) -> Response: + return self.request("DELETE", path, headers=headers, **kwargs) diff --git a/tests/destination/unit/test_destination_http_client.py b/tests/destination/unit/test_destination_http_client.py new file mode 100644 index 00000000..bbeee5c0 --- /dev/null +++ b/tests/destination/unit/test_destination_http_client.py @@ -0,0 +1,134 @@ +"""Unit tests for DestinationHttpClient.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from sap_cloud_sdk.destination._destination_http_client import DestinationHttpClient +from sap_cloud_sdk.destination._models import AuthToken, Destination + + +def _dest(**kwargs) -> Destination: + base = {"Name": "test", "Type": "HTTP", "URL": "https://example.com"} + base.update(kwargs) + return Destination.from_dict(base) + + +def _auth_token(key: str, value: str) -> AuthToken: + return AuthToken(type="Bearer", value="raw", http_header={"key": key, "value": value}) + + +class TestDestinationHttpClientInit: + def test_raises_for_non_http_destination(self): + dest = Destination.from_dict({"Name": "test", "Type": "RFC"}) + with pytest.raises(ValueError, match="only supports HTTP destinations"): + DestinationHttpClient(dest) + + def test_erp_headers_pre_baked(self): + dest = _dest(**{"sap-client": "100", "sap-language": "en"}) + client = DestinationHttpClient(dest) + assert client._session.headers["sap-client"] == "100" + assert client._session.headers["sap-language"] == "en" + + def test_no_erp_headers_when_properties_empty(self): + dest = _dest() + client = DestinationHttpClient(dest) + assert "sap-client" not in client._session.headers + assert "sap-language" not in client._session.headers + + def test_auth_header_pre_baked_from_auth_tokens(self): + dest = _dest() + dest.auth_tokens = [_auth_token("Authorization", "Bearer eyJ123")] + client = DestinationHttpClient(dest) + assert client._session.headers["Authorization"] == "Bearer eyJ123" + + def test_multiple_auth_tokens_all_injected(self): + dest = _dest() + dest.auth_tokens = [ + _auth_token("Authorization", "Bearer eyJ123"), + _auth_token("x-sap-security-session", "mysession"), + ] + client = DestinationHttpClient(dest) + assert client._session.headers["Authorization"] == "Bearer eyJ123" + assert client._session.headers["x-sap-security-session"] == "mysession" + + def test_error_token_with_empty_values_is_skipped(self): + dest = _dest() + dest.auth_tokens = [_auth_token("", "")] + client = DestinationHttpClient(dest) + assert "Authorization" not in client._session.headers + + def test_no_auth_header_when_auth_tokens_empty(self): + dest = _dest() + client = DestinationHttpClient(dest) + assert "Authorization" not in client._session.headers + + +class TestDestinationHttpClientRequest: + def setup_method(self): + self.dest = _dest() + self.client = DestinationHttpClient(self.dest) + self.mock_response = MagicMock() + + def test_constructs_full_url(self): + with patch.object(self.client._session, "request", return_value=self.mock_response) as mock_req: + self.client.request("GET", "/api/v1/users") + assert mock_req.call_args[1]["url"] == "https://example.com/api/v1/users" + + def test_uppercases_method(self): + with patch.object(self.client._session, "request", return_value=self.mock_response) as mock_req: + self.client.request("get", "/resource") + assert mock_req.call_args[1]["method"] == "GET" + + def test_passes_params(self): + with patch.object(self.client._session, "request", return_value=self.mock_response) as mock_req: + self.client.request("GET", "/resource", params={"$top": "10"}) + assert mock_req.call_args[1]["params"] == {"$top": "10"} + + def test_passes_json_body(self): + with patch.object(self.client._session, "request", return_value=self.mock_response) as mock_req: + self.client.request("POST", "/resource", json={"key": "value"}) + assert mock_req.call_args[1]["json"] == {"key": "value"} + + def test_passes_extra_headers(self): + with patch.object(self.client._session, "request", return_value=self.mock_response) as mock_req: + self.client.request("GET", "/resource", headers={"X-Custom": "yes"}) + assert mock_req.call_args[1]["headers"] == {"X-Custom": "yes"} + + def test_returns_response(self): + with patch.object(self.client._session, "request", return_value=self.mock_response): + assert self.client.request("GET", "/resource") is self.mock_response + + +class TestDestinationHttpClientVerbHelpers: + def setup_method(self): + self.client = DestinationHttpClient(_dest()) + self.mock_response = MagicMock() + + def _patch(self): + return patch.object(self.client._session, "request", return_value=self.mock_response) + + def test_get_uses_get_method(self): + with self._patch() as mock_req: + self.client.get("/r") + assert mock_req.call_args[1]["method"] == "GET" + + def test_post_uses_post_method(self): + with self._patch() as mock_req: + self.client.post("/r", json={"a": 1}) + assert mock_req.call_args[1]["method"] == "POST" + + def test_put_uses_put_method(self): + with self._patch() as mock_req: + self.client.put("/r", json={}) + assert mock_req.call_args[1]["method"] == "PUT" + + def test_patch_uses_patch_method(self): + with self._patch() as mock_req: + self.client.patch("/r", json={}) + assert mock_req.call_args[1]["method"] == "PATCH" + + def test_delete_uses_delete_method(self): + with self._patch() as mock_req: + self.client.delete("/r") + assert mock_req.call_args[1]["method"] == "DELETE" From c520e3a073966808f7be10b2db129bde27f484aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Mon, 18 May 2026 17:47:53 -0300 Subject: [PATCH 03/10] refactor: remove extra requests methods from DestinationHttpClient --- .../destination/_destination_http_client.py | 50 +------------------ .../unit/test_destination_http_client.py | 34 ------------- 2 files changed, 1 insertion(+), 83 deletions(-) diff --git a/src/sap_cloud_sdk/destination/_destination_http_client.py b/src/sap_cloud_sdk/destination/_destination_http_client.py index 7b0940b9..7e7f0b70 100644 --- a/src/sap_cloud_sdk/destination/_destination_http_client.py +++ b/src/sap_cloud_sdk/destination/_destination_http_client.py @@ -20,7 +20,7 @@ class DestinationHttpClient: dest = client.get_destination("my-erp") http = DestinationHttpClient(dest) - response = http.get("/sap/opu/odata/sap/API_BUSINESS_PARTNER") + response = http.request("GET", "/api/resource") """ def __init__(self, destination: Destination) -> None: @@ -77,51 +77,3 @@ def request( **kwargs, ) - def get( - self, - path: str, - *, - params: Optional[Dict[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs: Any, - ) -> Response: - return self.request("GET", path, params=params, headers=headers, **kwargs) - - def post( - self, - path: str, - *, - json: Optional[Any] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs: Any, - ) -> Response: - return self.request("POST", path, json=json, headers=headers, **kwargs) - - def put( - self, - path: str, - *, - json: Optional[Any] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs: Any, - ) -> Response: - return self.request("PUT", path, json=json, headers=headers, **kwargs) - - def patch( - self, - path: str, - *, - json: Optional[Any] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs: Any, - ) -> Response: - return self.request("PATCH", path, json=json, headers=headers, **kwargs) - - def delete( - self, - path: str, - *, - headers: Optional[Dict[str, str]] = None, - **kwargs: Any, - ) -> Response: - return self.request("DELETE", path, headers=headers, **kwargs) diff --git a/tests/destination/unit/test_destination_http_client.py b/tests/destination/unit/test_destination_http_client.py index bbeee5c0..7780908e 100644 --- a/tests/destination/unit/test_destination_http_client.py +++ b/tests/destination/unit/test_destination_http_client.py @@ -98,37 +98,3 @@ def test_passes_extra_headers(self): def test_returns_response(self): with patch.object(self.client._session, "request", return_value=self.mock_response): assert self.client.request("GET", "/resource") is self.mock_response - - -class TestDestinationHttpClientVerbHelpers: - def setup_method(self): - self.client = DestinationHttpClient(_dest()) - self.mock_response = MagicMock() - - def _patch(self): - return patch.object(self.client._session, "request", return_value=self.mock_response) - - def test_get_uses_get_method(self): - with self._patch() as mock_req: - self.client.get("/r") - assert mock_req.call_args[1]["method"] == "GET" - - def test_post_uses_post_method(self): - with self._patch() as mock_req: - self.client.post("/r", json={"a": 1}) - assert mock_req.call_args[1]["method"] == "POST" - - def test_put_uses_put_method(self): - with self._patch() as mock_req: - self.client.put("/r", json={}) - assert mock_req.call_args[1]["method"] == "PUT" - - def test_patch_uses_patch_method(self): - with self._patch() as mock_req: - self.client.patch("/r", json={}) - assert mock_req.call_args[1]["method"] == "PATCH" - - def test_delete_uses_delete_method(self): - with self._patch() as mock_req: - self.client.delete("/r") - assert mock_req.call_args[1]["method"] == "DELETE" From 449e2a555330bc8929b551ceeb541bddeb8fe974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Tue, 19 May 2026 16:50:50 -0300 Subject: [PATCH 04/10] feat: add get_headers to Destination with URL.headers.* support --- .../destination/_destination_http_client.py | 16 ++-------- src/sap_cloud_sdk/destination/_models.py | 29 +++++++++++++++---- .../unit/test_destination_http_client.py | 6 ++++ tests/destination/unit/test_models.py | 20 ++++++++++++- 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/src/sap_cloud_sdk/destination/_destination_http_client.py b/src/sap_cloud_sdk/destination/_destination_http_client.py index 7e7f0b70..5a032b20 100644 --- a/src/sap_cloud_sdk/destination/_destination_http_client.py +++ b/src/sap_cloud_sdk/destination/_destination_http_client.py @@ -13,8 +13,8 @@ class DestinationHttpClient: """Wraps requests.Session to call the target system described by a Destination. - Pre-bakes SAP ERP headers (sap-client, sap-language) and auth headers from - the destination so callers never have to set them manually. + Pre-bakes headers derived from the destination — ERP headers (sap-client, + sap-language), URL.headers.* properties, and auth tokens. Usage:: @@ -31,17 +31,7 @@ def __init__(self, destination: Destination) -> None: self._destination = destination self._session = requests.Session() - - # Pre-bake sap-client / sap-language — relevant mainly for OnPremise destinations - self._session.headers.update(destination.get_erp_headers()) - - # Pre-bake auth headers — BTP may return multiple tokens, skip empty ones - for token in destination.auth_tokens: - key = token.http_header.get("key") - value = token.http_header.get("value") - if key and value: - self._session.headers[key] = value - + self._session.headers.update(destination.get_headers()) self._base_url = destination.url.rstrip("/") if destination.url else "" def request( diff --git a/src/sap_cloud_sdk/destination/_models.py b/src/sap_cloud_sdk/destination/_models.py index d8368070..03a30579 100644 --- a/src/sap_cloud_sdk/destination/_models.py +++ b/src/sap_cloud_sdk/destination/_models.py @@ -367,13 +367,10 @@ def to_dict(self) -> Dict[str, Any]: return payload def get_erp_headers(self) -> Dict[str, str]: - """Return SAP ERP-specific headers derived from destination properties. - - Reads sap-client and sap-language from the destination properties and - returns them as HTTP headers. + """Return SAP ERP-specific headers derived from destination properties (sap-client, sap-language). Returns: - Dict[str, str]: Headers to inject into requests to the target system. + Headers to inject into requests to the target system. """ headers: Dict[str, str] = {} if "sap-client" in self.properties: @@ -382,6 +379,28 @@ def get_erp_headers(self) -> Dict[str, str]: headers["sap-language"] = self.properties["sap-language"] return headers + def get_headers(self) -> Dict[str, str]: + """Return HTTP headers derived from this destination (ERP headers, URL.headers.* properties, and auth tokens), each overriding the previous on conflicting keys. + + Returns: + Headers ready to inject into requests to the target system. + """ + headers: Dict[str, str] = {} + headers.update(self.get_erp_headers()) + + _PREFIX = "URL.headers." + for key, value in self.properties.items(): + if key.startswith(_PREFIX): + headers[key[len(_PREFIX) :]] = value + + for token in self.auth_tokens: + key = token.http_header.get("key") + value = token.http_header.get("value") + if key and value: + headers[key] = value + + return headers + @dataclass class AuthToken: diff --git a/tests/destination/unit/test_destination_http_client.py b/tests/destination/unit/test_destination_http_client.py index 7780908e..e0d8ea84 100644 --- a/tests/destination/unit/test_destination_http_client.py +++ b/tests/destination/unit/test_destination_http_client.py @@ -63,6 +63,12 @@ def test_no_auth_header_when_auth_tokens_empty(self): client = DestinationHttpClient(dest) assert "Authorization" not in client._session.headers + def test_url_headers_properties_pre_baked(self): + dest = _dest(**{"URL.headers.apiKey": "secret", "URL.headers.X-Tenant": "acme"}) + client = DestinationHttpClient(dest) + assert client._session.headers["apiKey"] == "secret" + assert client._session.headers["X-Tenant"] == "acme" + class TestDestinationHttpClientRequest: def setup_method(self): diff --git a/tests/destination/unit/test_models.py b/tests/destination/unit/test_models.py index 0df9fabb..7fa78271 100644 --- a/tests/destination/unit/test_models.py +++ b/tests/destination/unit/test_models.py @@ -5,7 +5,7 @@ import pytest from sap_cloud_sdk.destination._models import Destination, DestinationType, ProxyType, Authentication -from sap_cloud_sdk.destination._models import Fragment +from sap_cloud_sdk.destination._models import AuthToken, Fragment from sap_cloud_sdk.destination._models import Label, PatchLabels from sap_cloud_sdk.destination._models import ListOptions from sap_cloud_sdk.destination.config import DestinationConfig @@ -274,6 +274,24 @@ def test_get_erp_headers_ignores_other_properties(self): }) assert dest.get_erp_headers() == {"sap-client": "100"} + def test_get_headers_returns_empty_when_nothing_set(self): + dest = Destination.from_dict({"Name": "test", "Type": "HTTP"}) + assert dest.get_headers() == {} + + def test_get_headers_includes_erp_headers(self): + dest = Destination.from_dict({"Name": "test", "Type": "HTTP", "sap-client": "100", "sap-language": "EN"}) + assert dest.get_headers() == {"sap-client": "100", "sap-language": "EN"} + + def test_get_headers_includes_url_headers_properties(self): + dest = Destination.from_dict({"Name": "test", "Type": "HTTP", "URL.headers.apiKey": "secret"}) + assert dest.get_headers()["apiKey"] == "secret" + assert "URL.headers.apiKey" not in dest.get_headers() + + def test_get_headers_includes_auth_tokens(self): + dest = Destination.from_dict({"Name": "test", "Type": "HTTP"}) + dest.auth_tokens = [AuthToken(type="Bearer", value="raw", http_header={"key": "Authorization", "value": "Bearer eyJ123"})] + assert dest.get_headers()["Authorization"] == "Bearer eyJ123" + class TestFragmentModel: """Tests for Fragment dataclass.""" From 126cf066baca9aa6547049d2382a57e0c0cca1f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Tue, 19 May 2026 17:12:40 -0300 Subject: [PATCH 05/10] chore: deprecate get_instance_destination and get_subaccount_destination in favor of get_destination --- src/sap_cloud_sdk/destination/client.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/sap_cloud_sdk/destination/client.py b/src/sap_cloud_sdk/destination/client.py index ce3a068d..9d86db78 100644 --- a/src/sap_cloud_sdk/destination/client.py +++ b/src/sap_cloud_sdk/destination/client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import warnings from typing import List, Optional, Callable, TypeVar from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics @@ -202,6 +203,9 @@ def get_instance_destination( ) -> Optional[Destination | TransparentProxyDestination]: """Get a destination from the service instance scope. + .. deprecated:: + Use ``get_destination()`` instead, which automatically retrieves auth tokens via the v2 API. + Args: name: Destination name. proxy_enabled: Whether to route the request through a transparent proxy (if configured). @@ -213,6 +217,12 @@ def get_instance_destination( Raises: DestinationOperationError: If an HTTP error occurs or response parsing fails. """ + warnings.warn( + "get_instance_destination() is deprecated. " + "Use get_destination() instead, which also includes automatic token retrieval.", + DeprecationWarning, + stacklevel=2, + ) try: if self._should_use_proxy(proxy_enabled): return TransparentProxyDestination.from_proxy( @@ -237,6 +247,9 @@ def get_subaccount_destination( ) -> Optional[Destination | TransparentProxyDestination]: """Get a destination from the subaccount scope with an access strategy. + .. deprecated:: + Use ``get_destination()`` instead, which automatically retrieves auth tokens via the v2 API. + Access strategies: - SUBSCRIBER_ONLY: Fetch only from subscriber context (tenant required) - PROVIDER_ONLY: Fetch only from provider context (no tenant required) @@ -257,6 +270,12 @@ def get_subaccount_destination( DestinationOperationError: If tenant is missing for subscriber access strategies, on HTTP errors, or response parsing failures. """ + warnings.warn( + "get_subaccount_destination() is deprecated. " + "Use get_destination() instead, which also includes automatic token retrieval.", + DeprecationWarning, + stacklevel=2, + ) try: if self._should_use_proxy(proxy_enabled) and self._transparent_proxy: return TransparentProxyDestination.from_proxy( From 175f177e302752bf32c6c853a7ca0d829f907f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Wed, 20 May 2026 12:24:31 -0300 Subject: [PATCH 06/10] docs: add calling target systems section and deprecation notices to destination user guide --- src/sap_cloud_sdk/destination/user-guide.md | 59 +++++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/src/sap_cloud_sdk/destination/user-guide.md b/src/sap_cloud_sdk/destination/user-guide.md index 7d5c9ef3..dc6b8778 100644 --- a/src/sap_cloud_sdk/destination/user-guide.md +++ b/src/sap_cloud_sdk/destination/user-guide.md @@ -24,7 +24,7 @@ fragment_client = create_fragment_client(instance="default") certificate_client = create_certificate_client(instance="default") # Instance-level read -dest = client.get_instance_destination("my-destination") +dest = client.get_instance_destination("my-destination") # deprecated: use get_destination() fragment = fragment_client.get_instance_fragment("my-fragment") cert = certificate_client.get_instance_certificate("my-cert") @@ -39,12 +39,12 @@ fragments = fragment_client.list_instance_fragments(tenant="tenant-subdomain") certificates = certificate_client.list_instance_certificates(tenant="tenant-subdomain") # Subaccount-level read: provider only (no tenant required) -dest = client.get_subaccount_destination("my-destination", access_strategy=AccessStrategy.PROVIDER_ONLY) +dest = client.get_subaccount_destination("my-destination", access_strategy=AccessStrategy.PROVIDER_ONLY) # deprecated: use get_destination() fragment = fragment_client.get_subaccount_fragment("my-fragment", access_strategy=AccessStrategy.PROVIDER_ONLY) cert = certificate_client.get_subaccount_certificate("my-cert", access_strategy=AccessStrategy.PROVIDER_ONLY) # Subaccount-level read: subscriber-first (tenant required), fallback to provider -dest = client.get_subaccount_destination("my-destination", access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="tenant-subdomain") +dest = client.get_subaccount_destination("my-destination", access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="tenant-subdomain") # deprecated: use get_destination() fragment = fragment_client.get_subaccount_fragment("my-fragment", access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="tenant-subdomain") cert = certificate_client.get_subaccount_certificate("my-cert", access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="tenant-subdomain") @@ -126,8 +126,8 @@ The client produced by `create_client()` exposes the following operations: ```python class DestinationClient: # V1 Admin API - Read operations for destinations - def get_instance_destination(self, name: str, proxy_enabled: Optional[bool] = None) -> Optional[Destination | TransparentProxyDestination]: ... - def get_subaccount_destination(self, name: str, access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, tenant: Optional[str] = None, proxy_enabled: Optional[bool] = None) -> Optional[Destination | TransparentProxyDestination]: ... + def get_instance_destination(self, name: str, proxy_enabled: Optional[bool] = None) -> Optional[Destination | TransparentProxyDestination]: ... # deprecated: use get_destination() + def get_subaccount_destination(self, name: str, access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, tenant: Optional[str] = None, proxy_enabled: Optional[bool] = None) -> Optional[Destination | TransparentProxyDestination]: ... # deprecated: use get_destination() def list_instance_destinations(self, tenant: Optional[str] = None, filter: Optional[ListOptions] = None) -> PagedResult[Destination]: ... def list_subaccount_destinations(self, access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, tenant: Optional[str] = None, filter: Optional[ListOptions] = None) -> PagedResult[Destination]: ... @@ -218,6 +218,55 @@ class CertificateClient: - Certificate `content` should be base64-encoded. Supported certificate types include PEM, JKS, P12, etc. - The v2 consumption API returns tokens in the `auth_tokens` field with ready-to-use HTTP headers in `http_header` dict. +## Calling Target Systems + +`DestinationHttpClient` wraps `requests.Session` to call the target system described by a destination. It injects headers automatically so you don't have to handle auth tokens, ERP headers, or custom destination properties manually. + +> **Note:** `DestinationHttpClient` requires a destination fetched via the v2 API (`get_destination()`), which returns pre-fetched auth tokens. It does not support destinations fetched with the deprecated v1 methods. + +### Basic Usage + +```python +from sap_cloud_sdk.destination import create_client, DestinationHttpClient + +client = create_client(instance="default") +dest = client.get_destination("my-erp") + +http = DestinationHttpClient(dest) +response = http.request("GET", "/api/resource") +``` + +### What headers are pre-baked + +When `DestinationHttpClient` is constructed, it reads the destination and pre-bakes the following headers into every request: + +1. **ERP headers** — `sap-client` and `sap-language` from destination properties (if present) +2. **`URL.headers.*` properties** — any destination property prefixed with `URL.headers.` becomes a header (e.g. `URL.headers.apiKey = secret` → `apiKey: secret`) +3. **Auth tokens** — pre-fetched by BTP and returned in `dest.auth_tokens`; each token's `http_header` is injected directly (e.g. `Authorization: Bearer eyJ...`) + +Auth tokens take precedence over `URL.headers.*` properties if both set the same header key. + +### Per-request headers + +Pass `headers=` to add or override headers for a single request: + +```python +response = http.request("GET", "/api/resource", headers={"X-Correlation-ID": "abc123"}) +``` + +Per-request headers are merged on top of the pre-baked session headers. + +### Using `get_headers()` directly + +If you manage your own HTTP client, use `dest.get_headers()` to get all derived headers as a plain dict: + +```python +import requests + +dest = client.get_destination("my-erp") +response = requests.get(dest.url + "/api/resource", headers=dest.get_headers()) +``` + ## Transparent Proxy Support The destination client supports routing requests through a transparent proxy. This enables access to on-premise systems and private network resources through a proxy deployed in your Kubernetes cluster. From 0d579d63a79ee20de7b4963101d0da6502c5ee25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Tue, 26 May 2026 21:47:39 -0300 Subject: [PATCH 07/10] refactor(destination): remove unnecessary type validation and unused field in DestinationHttpClient --- src/sap_cloud_sdk/destination/_destination_http_client.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/sap_cloud_sdk/destination/_destination_http_client.py b/src/sap_cloud_sdk/destination/_destination_http_client.py index 5a032b20..9e948486 100644 --- a/src/sap_cloud_sdk/destination/_destination_http_client.py +++ b/src/sap_cloud_sdk/destination/_destination_http_client.py @@ -16,7 +16,7 @@ class DestinationHttpClient: Pre-bakes headers derived from the destination — ERP headers (sap-client, sap-language), URL.headers.* properties, and auth tokens. - Usage:: + Usage: dest = client.get_destination("my-erp") http = DestinationHttpClient(dest) @@ -24,12 +24,11 @@ class DestinationHttpClient: """ def __init__(self, destination: Destination) -> None: - if destination.type not in (DestinationType.HTTP, "HTTP"): + if destination.type != DestinationType.HTTP: raise ValueError( f"DestinationHttpClient only supports HTTP destinations, got: {destination.type}" ) - self._destination = destination self._session = requests.Session() self._session.headers.update(destination.get_headers()) self._base_url = destination.url.rstrip("/") if destination.url else "" @@ -66,4 +65,3 @@ def request( headers=headers, **kwargs, ) - From a311c4ae6cdbbd27d65e83aec1ee0dbee79695bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Tue, 26 May 2026 21:47:53 -0300 Subject: [PATCH 08/10] test(destination): add DestinationHttpClient integration test --- .../integration/destination.feature | 14 +++++++ .../integration/test_destination_bdd.py | 39 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/tests/destination/integration/destination.feature b/tests/destination/integration/destination.feature index 88c3c2aa..9a489b39 100644 --- a/tests/destination/integration/destination.feature +++ b/tests/destination/integration/destination.feature @@ -227,6 +227,19 @@ Feature: Destination Service Integration # And I clean up the instance destination "test-v2-full-options" # And I clean up the instance fragment "test-v2-full-fragment" + Scenario: DestinationHttpClient sends an authenticated request using token fetched from BTP + Given I have a destination named "sdk-test-http-client" of type "HTTP" + And the destination has URL "https://httpbin.org" + And the destination has authentication "OAuth2ClientCredentials" + And the destination has OAuth2 credentials from environment + When I create the destination at instance level + Then the destination creation should be successful + When I fetch the destination using the v2 API + And I create a DestinationHttpClient from the destination + And I send a GET request to "/headers" + Then the response contains an Authorization header + And I clean up the instance destination "sdk-test-http-client" + Scenario: Manage labels for subaccount destination Given I have a destination named "test-dest-labels" of type "HTTP" And the destination has URL "https://labels.example.com" @@ -303,3 +316,4 @@ Feature: Destination Service Integration # Then the destination creation should be successful # When I get subaccount destination "test-dest-sub-isolation" with "PROVIDER_ONLY" access strategy # Then the destination should not be found + diff --git a/tests/destination/integration/test_destination_bdd.py b/tests/destination/integration/test_destination_bdd.py index f2036450..a8e24890 100644 --- a/tests/destination/integration/test_destination_bdd.py +++ b/tests/destination/integration/test_destination_bdd.py @@ -1,6 +1,7 @@ """BDD step definitions for Destination integration tests.""" import concurrent.futures +import os from typing import List, Optional import pytest @@ -17,6 +18,7 @@ ConsumptionOptions, PatchLabels, ) +from sap_cloud_sdk.destination._destination_http_client import DestinationHttpClient from sap_cloud_sdk.destination.exceptions import ( HttpError, DestinationOperationError, @@ -57,6 +59,8 @@ def __init__(self): self.updated_certificate_content: Optional[str] = None self.tenant: Optional[str] = None self.retrieved_labels: List[Label] = [] + self.http_client: Optional[DestinationHttpClient] = None + self.http_response = None @pytest.fixture @@ -1572,3 +1576,38 @@ def certificate_should_have_label(context, key, value): lbl.key == key and value in lbl.values for lbl in context.retrieved_labels ), f"Expected label key='{key}' value='{value}' in {context.retrieved_labels}" + + +# ==================== DESTINATION HTTP CLIENT STEPS ==================== + +@given("the destination has OAuth2 credentials from environment") +def destination_has_oauth2_credentials(context): + context.destination.properties.update({ + "clientId": os.environ["CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTID"], + "clientSecret": os.environ["CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTSECRET"], + "tokenServiceURL": os.environ["CLOUD_SDK_CFG_DESTINATION_DEFAULT_URL"] + "/oauth/token", + }) + + +@when("I fetch the destination using the v2 API") +def fetch_destination_v2(context, destination_client): + context.retrieved_destination = destination_client.get_destination(context.destination.name) + + +@when("I create a DestinationHttpClient from the destination") +def create_http_client(context): + context.http_client = DestinationHttpClient(context.retrieved_destination) + + +@when(parsers.parse('I send a GET request to "{path}"')) +def send_get_request(context, path): + context.http_response = context.http_client.request("GET", path) + + +@then("the response contains an Authorization header") +def assert_authorization_header_present(context): + echoed = context.http_response.json().get("headers", {}) + assert "Authorization" in echoed, ( + f"Expected Authorization header in response, got: {list(echoed.keys())}. " + "Check that BTP returned an auth token for the destination." + ) From c77e090407f6ada432842a4e288b0c4d57662e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Tue, 26 May 2026 21:53:32 -0300 Subject: [PATCH 09/10] chore: bump version to v0.21.0 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 23af4bf7..27269269 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.20.1" +version = "0.21.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/uv.lock b/uv.lock index 0e5cebdc..a325a6f0 100644 --- a/uv.lock +++ b/uv.lock @@ -2924,7 +2924,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.19.3" +version = "0.21.0" source = { editable = "." } dependencies = [ { name = "grpcio" }, From d4f0915f4fccadfe5be45bcd7e8f808e286120a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Wed, 27 May 2026 13:03:02 -0300 Subject: [PATCH 10/10] fix(destination): mark deprecated methods with deprecated=True in record_metrics --- src/sap_cloud_sdk/destination/client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/sap_cloud_sdk/destination/client.py b/src/sap_cloud_sdk/destination/client.py index 9d86db78..20bf3c60 100644 --- a/src/sap_cloud_sdk/destination/client.py +++ b/src/sap_cloud_sdk/destination/client.py @@ -197,7 +197,11 @@ def list_subaccount_destinations( f"failed to list subaccount destinations: {e}" ) - @record_metrics(Module.DESTINATION, Operation.DESTINATION_GET_INSTANCE_DESTINATION) + @record_metrics( + Module.DESTINATION, + Operation.DESTINATION_GET_INSTANCE_DESTINATION, + deprecated=True, + ) def get_instance_destination( self, name: str, proxy_enabled: Optional[bool] = None ) -> Optional[Destination | TransparentProxyDestination]: @@ -236,7 +240,9 @@ def get_instance_destination( raise DestinationOperationError(f"failed to get destination '{name}': {e}") @record_metrics( - Module.DESTINATION, Operation.DESTINATION_GET_SUBACCOUNT_DESTINATION + Module.DESTINATION, + Operation.DESTINATION_GET_SUBACCOUNT_DESTINATION, + deprecated=True, ) def get_subaccount_destination( self,