-
Notifications
You must be signed in to change notification settings - Fork 22
feat: add DestinationHttpClient to simplify calls to target systems #119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a1035b9
d4ded12
53cf8e1
6543dbb
201a436
e9fa7d2
61c1c68
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,69 @@ | ||||||
| """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. | ||||||
|
NicoleMGomes marked this conversation as resolved.
|
||||||
|
|
||||||
| Pre-bakes headers derived from the destination — ERP headers (sap-client, | ||||||
| sap-language), URL.headers.* properties, and auth tokens. | ||||||
|
|
||||||
| Usage:: | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
| dest = client.get_destination("my-erp") | ||||||
| http = DestinationHttpClient(dest) | ||||||
| response = http.request("GET", "/api/resource") | ||||||
| """ | ||||||
|
|
||||||
| def __init__(self, destination: Destination) -> None: | ||||||
| if destination.type not in (DestinationType.HTTP, "HTTP"): | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why both |
||||||
| 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()) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe we should also validate if authentication header is present |
||||||
| 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, | ||||||
| ) | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| """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 | ||
|
|
||
| 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): | ||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Increate minor version, since it's a new feature.