diff --git a/pyproject.toml b/pyproject.toml index 5be79f0..5a431fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.7.1" +version = "0.8.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/destination/_models.py b/src/sap_cloud_sdk/destination/_models.py index eaeb0bf..1a76b55 100644 --- a/src/sap_cloud_sdk/destination/_models.py +++ b/src/sap_cloud_sdk/destination/_models.py @@ -405,11 +405,53 @@ def from_dict(cls, obj: Dict[str, Any]) -> "AuthToken": class ConsumptionOptions: """Options for consuming a destination via the v2 runtime API. - This class encapsulates optional parameters for destination consumption. + Each field maps directly to an HTTP request header sent to the Destination Service. Fields: - fragment_name: Optional fragment name for property merging via X-fragment-name header - tenant: Optional subscriber tenant subdomain for user token exchange + fragment_name: Name of the destination fragment used to override/extend destination + properties (X-fragment-name). In case of overlapping properties, fragment values + take priority. + fragment_optional: When True, if the fragment specified by fragment_name does not + exist the destination is returned without it. When False (default), a missing + fragment causes an error (X-fragment-optional). + tenant: Subdomain of the tenant on behalf of which to fetch an access token + (X-tenant). Required when tokenServiceURLType is Common. Takes precedence over + user_token for tenant determination. + user_token: Encoded user JWT token (RFC 7519) for authentication types that require + user information: OAuth2UserTokenExchange, OAuth2JWTBearer, + OAuth2SAMLBearerAssertion (X-user-token). Takes priority over the Authorization + header for token exchange. + subject_token: Subject token for OAuth2TokenExchange destinations (X-subject-token). + Used as the subject_token parameter in the token exchange request (RFC 8693). + Must be used together with subject_token_type. + subject_token_type: Format of the subject token as defined by the authorization + server (X-subject-token-type), e.g. + "urn:ietf:params:oauth:token-type:access_token". Required with subject_token. + actor_token: Actor token for OAuth2TokenExchange destinations (X-actor-token). + Used as the actor_token parameter in the token exchange request (RFC 8693). + Should be used together with actor_token_type. + actor_token_type: Format of the actor token as defined by the authorization server + (X-actor-token-type), e.g. "urn:ietf:params:oauth:token-type:access_token". + saml_assertion: Client-provided SAML assertion for destinations with authentication + type OAuth2SAMLBearerAssertion and SAMLAssertionProvider=ClientProvided + (X-samlAssertion). If applicable but not provided, token retrieval will fail. + refresh_token: Refresh token for OAuth2RefreshToken destinations (X-refresh-token). + Mandatory for that authentication type. The service uses it to fetch new access + and refresh tokens from the configured tokenServiceURL. + code: Authorization code for OAuth2AuthorizationCode destinations (X-code). + Mandatory for that authentication type. Exchanged for an access token at the + configured tokenServiceURL. + redirect_uri: URL-encoded redirect URI for OAuth2AuthorizationCode destinations + (X-redirect-uri). Required when the same redirect URI was registered during the + authorization code grant; must match the registered value. + code_verifier: PKCE code verifier for OAuth2AuthorizationCode destinations + (X-code-verifier). Required when a code challenge was provided during the + authorization code grant. + chain_name: Name of a predefined destination chain, enabling multiple Destination + Service interactions in a single request (X-chain-name). + chain_vars: Key-value pairs for destination chain variables (X-chain-var-). + Each entry is sent as a separate "X-chain-var-" header. Only applicable + when chain_name is provided. Example: ```python @@ -417,23 +459,48 @@ class ConsumptionOptions: client = create_client() - # Simple consumption - dest = client.get_destination("my-api") + # Fragment merging + dest = client.get_destination("my-api", options=ConsumptionOptions(fragment_name="prod")) - # With options - options = ConsumptionOptions(fragment_name="production", tenant="tenant-1") - dest = client.get_destination("my-api", options=options) + # User token exchange + opts = ConsumptionOptions(user_token="", tenant="tenant-1") + dest = client.get_destination("my-api", options=opts) - # Or inline - dest = client.get_destination( - "my-api", - options=ConsumptionOptions(fragment_name="prod") + # OAuth2TokenExchange + opts = ConsumptionOptions( + subject_token="", + subject_token_type="urn:ietf:params:oauth:token-type:access_token", ) + dest = client.get_destination("my-api", options=opts) + + # OAuth2AuthorizationCode + opts = ConsumptionOptions(code="", redirect_uri="https://app/callback") + dest = client.get_destination("my-api", options=opts) + + # Destination chain + opts = ConsumptionOptions( + chain_name="my-chain", + chain_vars={"subject_token": "", "subject_token_type": "access_token"}, + ) + dest = client.get_destination("my-api", options=opts) ``` """ fragment_name: Optional[str] = None + fragment_optional: Optional[bool] = None tenant: Optional[str] = None + user_token: Optional[str] = None + subject_token: Optional[str] = None + subject_token_type: Optional[str] = None + actor_token: Optional[str] = None + actor_token_type: Optional[str] = None + saml_assertion: Optional[str] = None + refresh_token: Optional[str] = None + code: Optional[str] = None + redirect_uri: Optional[str] = None + code_verifier: Optional[str] = None + chain_name: Optional[str] = None + chain_vars: Optional[dict] = None @dataclass diff --git a/src/sap_cloud_sdk/destination/client.py b/src/sap_cloud_sdk/destination/client.py index 21daa5e..dfc4c30 100644 --- a/src/sap_cloud_sdk/destination/client.py +++ b/src/sap_cloud_sdk/destination/client.py @@ -289,10 +289,13 @@ def get_destination( Args: name: Destination name. - level: Optional level hint (subaccount or instance) to optimize lookup. If not provided, the API will search on instance level. - options: Optional ConsumptionOptions for fragment merging and tenant context. - proxy_enabled: Whether to route the request through a transparent proxy (if configured). - If None, uses the client's default proxy_enabled setting. + level: Optional level hint (subaccount or instance) to optimize lookup. If not + provided, the API will search on instance level. + options: Optional ConsumptionOptions controlling request headers sent to the + Destination Service. See ConsumptionOptions for the full list of supported + headers (fragment merging, token exchange, SAML, OAuth2 flows, chains, etc.). + proxy_enabled: Whether to route the request through a transparent proxy (if + configured). If None, uses the client's default proxy_enabled setting. Returns: Destination with auth_tokens and certificates populated from v2 API, @@ -310,23 +313,27 @@ def get_destination( # Simple consumption dest = client.get_destination("my-api") - print(dest.url) - print(dest.auth_tokens) # With level hint dest = client.get_destination("my-api", level=Level.SERVICE_INSTANCE) - # With options - fragment merging - opts = ConsumptionOptions(fragment_name="production") - dest = client.get_destination("my-api", options=opts) + # Fragment merging + dest = client.get_destination("my-api", options=ConsumptionOptions(fragment_name="prod")) - # With tenant context for user token exchange - opts = ConsumptionOptions(tenant="tenant-subdomain") - dest = client.get_destination("my-api", options=opts) + # Optional fragment (no error if fragment not found) + dest = client.get_destination( + "my-api", + options=ConsumptionOptions(fragment_name="prod", fragment_optional=True), + ) - # Both fragment and tenant - opts = ConsumptionOptions(fragment_name="prod", tenant="tenant-1") - dest = client.get_destination("my-api", options=opts) + # Tenant context + dest = client.get_destination("my-api", options=ConsumptionOptions(tenant="tenant-1")) + + # User token exchange (OAuth2UserTokenExchange / OAuth2JWTBearer) + dest = client.get_destination( + "my-api", + options=ConsumptionOptions(user_token="", tenant="tenant-1"), + ) # With transparent proxy enabled dest = client.get_destination("my-api", proxy_enabled=True) @@ -343,8 +350,37 @@ def get_destination( if options: if options.fragment_name: headers["X-fragment-name"] = options.fragment_name + if options.fragment_optional is not None: + headers["X-fragment-optional"] = str( + options.fragment_optional + ).lower() if options.tenant: headers["X-tenant"] = options.tenant + if options.user_token: + headers["X-user-token"] = options.user_token + if options.subject_token: + headers["X-subject-token"] = options.subject_token + if options.subject_token_type: + headers["X-subject-token-type"] = options.subject_token_type + if options.actor_token: + headers["X-actor-token"] = options.actor_token + if options.actor_token_type: + headers["X-actor-token-type"] = options.actor_token_type + if options.saml_assertion: + headers["X-samlAssertion"] = options.saml_assertion + if options.refresh_token: + headers["X-refresh-token"] = options.refresh_token + if options.code: + headers["X-code"] = options.code + if options.redirect_uri: + headers["X-redirect-uri"] = options.redirect_uri + if options.code_verifier: + headers["X-code-verifier"] = options.code_verifier + if options.chain_name: + headers["X-chain-name"] = options.chain_name + if options.chain_vars: + for var_name, var_value in options.chain_vars.items(): + headers[f"X-chain-var-{var_name}"] = var_value # Build path with optional level hint if level: diff --git a/src/sap_cloud_sdk/destination/user-guide.md b/src/sap_cloud_sdk/destination/user-guide.md index 6900e17..3b92799 100644 --- a/src/sap_cloud_sdk/destination/user-guide.md +++ b/src/sap_cloud_sdk/destination/user-guide.md @@ -107,7 +107,22 @@ class CertificateClient: - `Destination(name: str, type: str, url?: str, proxy_type?: str, authentication?: str, description?: str, properties?: dict[str, str], auth_tokens?: list[AuthToken], certificates?: list[Certificate])` - `auth_tokens` and `certificates` are populated by the v2 consumption API -- `ConsumptionOptions(fragment_name?: str, tenant?: str)` - Options for v2 destination consumption +- `ConsumptionOptions` - Options for v2 destination consumption, controls HTTP headers sent to the Destination Service: + - `fragment_name?: str` - Fragment to merge into the destination (`X-fragment-name`) + - `fragment_optional?: bool` - If `True`, a missing fragment does not cause an error (`X-fragment-optional`) + - `tenant?: str` - Tenant subdomain for token retrieval (`X-tenant`) + - `user_token?: str` - User JWT for OAuth2UserTokenExchange / OAuth2JWTBearer / OAuth2SAMLBearerAssertion (`X-user-token`) + - `subject_token?: str` - Subject token for OAuth2TokenExchange (`X-subject-token`) + - `subject_token_type?: str` - Format of the subject token (`X-subject-token-type`), e.g. `"urn:ietf:params:oauth:token-type:access_token"` + - `actor_token?: str` - Actor token for OAuth2TokenExchange (`X-actor-token`) + - `actor_token_type?: str` - Format of the actor token (`X-actor-token-type`) + - `saml_assertion?: str` - Client-provided SAML assertion for OAuth2SAMLBearerAssertion with `SAMLAssertionProvider=ClientProvided` (`X-samlAssertion`) + - `refresh_token?: str` - Refresh token for OAuth2RefreshToken destinations (`X-refresh-token`) + - `code?: str` - Authorization code for OAuth2AuthorizationCode destinations (`X-code`) + - `redirect_uri?: str` - Redirect URI for OAuth2AuthorizationCode destinations (`X-redirect-uri`) + - `code_verifier?: str` - PKCE code verifier for OAuth2AuthorizationCode destinations (`X-code-verifier`) + - `chain_name?: str` - Name of a predefined destination chain (`X-chain-name`) + - `chain_vars?: dict[str, str]` - Variables for the destination chain; each entry is sent as `X-chain-var-` - `AuthToken(type: str, value: str, http_header: dict, expires_in?: str, error?: str, scope?: str, refresh_token?: str)` - Authentication token from v2 API - `Fragment(name: str, properties: dict[str, str])` - `Certificate(name: str, content: str, type: str)` @@ -247,6 +262,46 @@ dest = client.get_destination("my-api") # Example 9: Combine level with options options = ConsumptionOptions(fragment_name="production", tenant="tenant-1") dest = client.get_destination("my-api", level=Level.SUB_ACCOUNT, options=options) + +# Example 10: Optional fragment (no error if fragment does not exist) +options = ConsumptionOptions(fragment_name="maybe-exists", fragment_optional=True) +dest = client.get_destination("my-api", options=options) + +# Example 11: User token exchange (OAuth2UserTokenExchange / OAuth2JWTBearer) +options = ConsumptionOptions(user_token="", tenant="tenant-1") +dest = client.get_destination("my-api", options=options) + +# Example 12: OAuth2TokenExchange with subject and actor tokens +options = ConsumptionOptions( + subject_token="", + subject_token_type="urn:ietf:params:oauth:token-type:access_token", + actor_token="", + actor_token_type="urn:ietf:params:oauth:token-type:access_token", +) +dest = client.get_destination("my-api", options=options) + +# Example 13: Client-provided SAML assertion (SAMLAssertionProvider=ClientProvided) +options = ConsumptionOptions(saml_assertion="") +dest = client.get_destination("my-api", options=options) + +# Example 14: OAuth2RefreshToken +options = ConsumptionOptions(refresh_token="") +dest = client.get_destination("my-api", options=options) + +# Example 15: OAuth2AuthorizationCode with PKCE +options = ConsumptionOptions( + code="", + redirect_uri="https://myapp/callback", + code_verifier="", +) +dest = client.get_destination("my-api", options=options) + +# Example 16: Destination chain with chain variables +options = ConsumptionOptions( + chain_name="my-predefined-chain", + chain_vars={"subject_token": "", "subject_token_type": "access_token"}, +) +dest = client.get_destination("my-api", options=options) ``` ### Return Type diff --git a/tests/destination/unit/test_client.py b/tests/destination/unit/test_client.py index c2bfcef..810848a 100644 --- a/tests/destination/unit/test_client.py +++ b/tests/destination/unit/test_client.py @@ -481,8 +481,227 @@ def test_get_destination_empty_auth_tokens_and_certificates(self): assert len(result.auth_tokens) == 0 assert len(result.certificates) == 0 + def _make_simple_resp(self, mock_http): + """Helper: configure mock_http to return a minimal valid v2 response.""" + resp = MagicMock(spec=Response) + resp.status_code = 200 + resp.json.return_value = { + "destinationConfiguration": {"name": "my-api", "type": "HTTP", "url": "https://api.example.com"}, + "authTokens": [], + "certificates": [], + } + mock_http.get.return_value = resp + + def test_get_destination_with_fragment_optional_true(self): + """X-fragment-optional: true is sent when fragment_optional=True.""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination("my-api", options=ConsumptionOptions(fragment_name="prod", fragment_optional=True)) + + _, kwargs = mock_http.get.call_args + assert kwargs["headers"]["X-fragment-optional"] == "true" + + def test_get_destination_with_fragment_optional_false(self): + """X-fragment-optional: false is sent when fragment_optional=False.""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination("my-api", options=ConsumptionOptions(fragment_name="prod", fragment_optional=False)) + + _, kwargs = mock_http.get.call_args + assert kwargs["headers"]["X-fragment-optional"] == "false" + + def test_get_destination_fragment_optional_not_sent_when_none(self): + """X-fragment-optional header is omitted when fragment_optional is not set.""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination("my-api", options=ConsumptionOptions(fragment_name="prod")) + + _, kwargs = mock_http.get.call_args + assert "X-fragment-optional" not in kwargs["headers"] + + def test_get_destination_with_user_token(self): + """X-user-token header is sent for OAuth2UserTokenExchange flows.""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination("my-api", options=ConsumptionOptions(user_token="my.jwt.token")) + + _, kwargs = mock_http.get.call_args + assert kwargs["headers"]["X-user-token"] == "my.jwt.token" + + def test_get_destination_with_subject_token_and_type(self): + """X-subject-token and X-subject-token-type are sent for OAuth2TokenExchange.""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination( + "my-api", + options=ConsumptionOptions( + subject_token="subj-token", + subject_token_type="urn:ietf:params:oauth:token-type:access_token", + ), + ) + + _, kwargs = mock_http.get.call_args + assert kwargs["headers"]["X-subject-token"] == "subj-token" + assert kwargs["headers"]["X-subject-token-type"] == "urn:ietf:params:oauth:token-type:access_token" + + def test_get_destination_with_actor_token_and_type(self): + """X-actor-token and X-actor-token-type are sent for OAuth2TokenExchange.""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination( + "my-api", + options=ConsumptionOptions( + actor_token="actor-token", + actor_token_type="urn:ietf:params:oauth:token-type:access_token", + ), + ) + + _, kwargs = mock_http.get.call_args + assert kwargs["headers"]["X-actor-token"] == "actor-token" + assert kwargs["headers"]["X-actor-token-type"] == "urn:ietf:params:oauth:token-type:access_token" + + def test_get_destination_with_saml_assertion(self): + """X-samlAssertion is sent for OAuth2SAMLBearerAssertion with ClientProvided.""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination("my-api", options=ConsumptionOptions(saml_assertion="base64saml==")) + + _, kwargs = mock_http.get.call_args + assert kwargs["headers"]["X-samlAssertion"] == "base64saml==" + + def test_get_destination_with_refresh_token(self): + """X-refresh-token is sent for OAuth2RefreshToken destinations.""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination("my-api", options=ConsumptionOptions(refresh_token="my-refresh-token")) + + _, kwargs = mock_http.get.call_args + assert kwargs["headers"]["X-refresh-token"] == "my-refresh-token" + + def test_get_destination_with_code(self): + """X-code is sent for OAuth2AuthorizationCode destinations.""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination("my-api", options=ConsumptionOptions(code="auth-code-123")) + + _, kwargs = mock_http.get.call_args + assert kwargs["headers"]["X-code"] == "auth-code-123" + + def test_get_destination_with_redirect_uri(self): + """X-redirect-uri is sent for OAuth2AuthorizationCode destinations.""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination( + "my-api", + options=ConsumptionOptions(code="auth-code-123", redirect_uri="https://app/callback"), + ) + + _, kwargs = mock_http.get.call_args + assert kwargs["headers"]["X-redirect-uri"] == "https://app/callback" + + def test_get_destination_with_code_verifier(self): + """X-code-verifier is sent for PKCE-enabled OAuth2AuthorizationCode destinations.""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination( + "my-api", + options=ConsumptionOptions(code="auth-code-123", code_verifier="pkce-verifier-abc"), + ) + + _, kwargs = mock_http.get.call_args + assert kwargs["headers"]["X-code-verifier"] == "pkce-verifier-abc" + + def test_get_destination_with_chain_name(self): + """X-chain-name is sent when chain_name is provided.""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination("my-api", options=ConsumptionOptions(chain_name="my-chain")) + + _, kwargs = mock_http.get.call_args + assert kwargs["headers"]["X-chain-name"] == "my-chain" + + def test_get_destination_with_chain_vars(self): + """X-chain-var- headers are sent for each chain variable.""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination( + "my-api", + options=ConsumptionOptions( + chain_name="my-chain", + chain_vars={"subject_token": "tok123", "subject_token_type": "access_token"}, + ), + ) + + _, kwargs = mock_http.get.call_args + assert kwargs["headers"]["X-chain-name"] == "my-chain" + assert kwargs["headers"]["X-chain-var-subject_token"] == "tok123" + assert kwargs["headers"]["X-chain-var-subject_token_type"] == "access_token" + + def test_get_destination_chain_vars_without_chain_name(self): + """chain_vars without chain_name: headers are still forwarded (API enforces pairing).""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination( + "my-api", + options=ConsumptionOptions(chain_vars={"subject_token": "tok"}), + ) + + _, kwargs = mock_http.get.call_args + assert kwargs["headers"]["X-chain-var-subject_token"] == "tok" + assert "X-chain-name" not in kwargs["headers"] + + def test_get_destination_all_headers_combined(self): + """Multiple unrelated headers can be sent simultaneously.""" + mock_http = MagicMock() + self._make_simple_resp(mock_http) + + client = DestinationClient(mock_http) + client.get_destination( + "my-api", + options=ConsumptionOptions( + fragment_name="prod", + fragment_optional=True, + tenant="tenant-1", + user_token="user.jwt", + ), + ) + + _, kwargs = mock_http.get.call_args + assert kwargs["headers"]["X-fragment-name"] == "prod" + assert kwargs["headers"]["X-fragment-optional"] == "true" + assert kwargs["headers"]["X-tenant"] == "tenant-1" + assert kwargs["headers"]["X-user-token"] == "user.jwt" + + -class TestDestinationClientWithTransparentProxy: """Test suite for DestinationClient operations with transparent proxy enabled.""" @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") diff --git a/uv.lock b/uv.lock index 93ca8b1..714b92d 100644 --- a/uv.lock +++ b/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.7.1" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "grpcio" },