diff --git a/.gitignore b/.gitignore index ae33b087..e632cb42 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ env.bak/ venv.bak/ piperBuild-env/ +# Local scratch +tmp/ + # IDEs .vscode/ .idea/ diff --git a/pyproject.toml b/pyproject.toml index b3a151bd..94270634 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.57.2" +version = "0.58.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/cbc/__init__.py b/src/sap_cloud_sdk/cbc/__init__.py new file mode 100644 index 00000000..849e17f9 --- /dev/null +++ b/src/sap_cloud_sdk/cbc/__init__.py @@ -0,0 +1,108 @@ +"""SAP Cloud SDK for Python — CBC (Central Business Configuration) module. + +Provides a typed Python client for reading tenant-specific business configuration +from SAP Central Business Configuration (CBC). + +CBC is an SAP service that manages tenant-specific business configuration for +SAP cloud applications and AI agents. + +Quick start:: + + from sap_cloud_sdk import cbc + + cbc_client = cbc.create_client( + base_url=lambda: resolve_cbc_url(), + app_tenant_id=lambda: resolve_app_tenant_id(), + ssl_context=lambda: build_ssl_ctx(), + ) + config = cbc_client.get_configuration() + + # Access entity data + payment = config.get_config_object("payment-config") + if payment: + for row in payment.get_entity("payment-mode").data.as_list(): + print(row) + +Apps running on the SAP application platform can use +:func:`~sap_cloud_sdk.cbc.client_adapter.create_agent_client` instead, which supplies the +two resolvers and the mTLS context from the platform's provisioning conventions +— see the CBC user guide. +""" + +from __future__ import annotations + +from sap_cloud_sdk.cbc.client import ( + CBCClient, + DefaultClient, + create_client, +) +from sap_cloud_sdk.cbc.client_adapter import ( + CBC_FRAGMENT_PREFIX, + app_tenant_id_var, + create_agent_client, + load_ssl_context, + resolve_app_tenant_id, + resolve_base_url, + tenant_subdomain_var, +) +from sap_cloud_sdk.cbc.config import ( + CBCDestinationConfig, +) +from sap_cloud_sdk.cbc.exceptions import ( + CBCError, + CBCClientError, + CBCConfigError, + CBCHttpError, + CBCNetworkError, + CBCServerError, + HttpContext, +) +from sap_cloud_sdk.cbc._models import ( + ApiError, + ConfigData, + ConfigObject, + ConsumptionVersion, + ConsumptionVersions, + EntityContent, + EntityData, + NNV, +) + + +__all__ = [ + # factories + "create_client", + "create_agent_client", + # clients + "CBCClient", + "DefaultClient", + # platform adapter + "app_tenant_id_var", + "tenant_subdomain_var", + "CBC_FRAGMENT_PREFIX", + "CBCDestinationConfig", + # platform resolvers (compose with create_client to override one axis) + "resolve_base_url", + "resolve_app_tenant_id", + "load_ssl_context", + # exceptions + "CBCError", + "CBCClientError", + "CBCConfigError", + "CBCHttpError", + "CBCNetworkError", + "CBCServerError", + "HttpContext", + # models — consumption versions + "ConsumptionVersion", + "ConsumptionVersions", + "NNV", + # models — entities + "EntityContent", + "EntityData", + "ConfigObject", + # models — configuration + "ConfigData", + # models — api error + "ApiError", +] diff --git a/src/sap_cloud_sdk/cbc/_models.py b/src/sap_cloud_sdk/cbc/_models.py new file mode 100644 index 00000000..d099d95b --- /dev/null +++ b/src/sap_cloud_sdk/cbc/_models.py @@ -0,0 +1,302 @@ +"""Data models for the CBC (Central Business Configuration) module. + +All models use Pydantic v2 with ``frozen=True`` and camelCase alias support +so they map directly to the CBC REST API JSON. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import datetime +from typing import Any, cast +from pydantic import BaseModel, ConfigDict, Field + + +class _FrozenModel(BaseModel): + """Base model: immutable, accepts both snake_case and camelCase field names.""" + + model_config = ConfigDict(frozen=True, populate_by_name=True) + + +# --------------------------------------------------------------------------- +# Consumption version models +# --------------------------------------------------------------------------- + + +class NNV(_FrozenModel): + """Namespace-Name-Version tuple identifying a reference content version.""" + + namespace: str + name: str + version: str + + def __str__(self) -> str: + return f"{self.namespace}.{self.name}.{self.version}" + + +class ConsumptionVersion(_FrozenModel): + """A snapshot of the business configuration for an app tenant at a point in time. + + Attributes: + version: Version identifier. + created_date: Creation timestamp. + modified_date: Last modification timestamp. + ref_content: Reference content NNV this version is based on, if applicable. + """ + + version: str + created_date: datetime | None = Field(default=None, alias="createdDate") + modified_date: datetime | None = Field(default=None, alias="modifiedDate") + ref_content: NNV | None = Field(default=None, alias="referenceContentDetails") + + +class ConsumptionVersions(_FrozenModel): + """Collection of consumption versions returned by the CBC API. + + Attributes: + items: List of :class:`ConsumptionVersion` objects. + """ + + items: list[ConsumptionVersion] + + def latest(self) -> ConsumptionVersion | None: + """Return the latest version. + + Prefers most-recent ``modifiedDate``; falls back to ``createdDate``; falls + back to last item in the list. + + Returns: + Latest :class:`ConsumptionVersion`, or ``None`` if the list is empty. + """ + if not self.items: + return None + dated = [v for v in self.items if v.modified_date is not None] + if dated: + return max(dated, key=lambda v: cast(datetime, v.modified_date)) + created = [v for v in self.items if v.created_date is not None] + if created: + return max(created, key=lambda v: cast(datetime, v.created_date)) + return self.items[-1] + + +# --------------------------------------------------------------------------- +# Entity models +# --------------------------------------------------------------------------- + + +class ConfigObjectEntity(_FrozenModel): + """One entity listed under a config object by the ``configurationObjects`` API. + + Attributes: + entity_id: Authored entity key (e.g. ``"payment-mode"``), used directly + in the entity-data URL path. + """ + + entity_id: str = Field(alias="entityId") + + +class ConfigObjectEntry(_FrozenModel): + """One config object and its entities as returned by the API. + + Attributes: + config_object_id: Authored config object identifier (e.g. ``"payment-config"``). + entities: Entities belonging to this config object. + """ + + config_object_id: str = Field(alias="configurationObjectId") + entities: list[ConfigObjectEntity] + + +class ConfigObjectList(_FrozenModel): + """Config objects for a consumption version, already grouped by the API. + + Attributes: + items: List of :class:`ConfigObjectEntry` objects. + """ + + items: list[ConfigObjectEntry] + + +class EntityContent: + """Configuration content for an entity. + + Wraps the raw API response data and enforces shape at access time. + """ + + def __init__(self, raw: list[dict[str, Any]] | dict[str, Any]) -> None: + self._raw = raw + + def is_list(self) -> bool: + """Return ``True`` if the content is a list (``as_list()`` is safe to call).""" + return isinstance(self._raw, list) + + def is_object(self) -> bool: + """Return ``True`` if the content is a dict (``as_object()`` is safe to call).""" + return isinstance(self._raw, dict) + + def value(self) -> list[dict[str, Any]] | dict[str, Any]: + """Return the content as-is, without asserting its shape. + + Use :meth:`as_list` / :meth:`as_object` when you expect a specific shape, + or :meth:`is_list` / :meth:`is_object` to check first. + """ + return self._raw + + def as_list(self) -> list[dict[str, Any]]: + """Return the content as a list of objects. + + Raises: + ValueError: If the content is a dict, not a list. + """ + if not isinstance(self._raw, list): + raise ValueError( + "Entity data is a dict, not a list — use as_object() instead." + ) + return self._raw + + def as_object(self) -> dict[str, Any]: + """Return the content as a dict. + + Raises: + ValueError: If the content is a list, not a dict. + """ + if not isinstance(self._raw, dict): + raise ValueError( + "Entity data is a list, not a dict — use as_list() instead." + ) + return self._raw + + def __repr__(self) -> str: + return f"EntityContent({self._raw!r})" + + +@dataclass +class EntityData: + """Configuration content for a single entity. + + Attributes: + entity_id: Authored entity identifier (e.g. ``"payment-mode"``). + data: Configuration content for this entity. + """ + + entity_id: str + data: EntityContent + + +@dataclass +class ConfigObject: + """A configuration object and its entities. + + Attributes: + config_object_id: Authored config object identifier (e.g. ``"payment-config"``). + entities: Entity data for all entities in this config object. + """ + + config_object_id: str + entities: list[EntityData] + + def get_entity(self, entity_id: str) -> EntityData | None: + """Return entity data for the given entity ID. + + Args: + entity_id: Authored entity identifier. + + Returns: + Matching :class:`EntityData`, or ``None`` if not found. + """ + return next((e for e in self.entities if e.entity_id == entity_id), None) + + +@dataclass +class ConfigData: + """Complete business configuration — all config objects for one consumption version. + + Attributes: + consumption_version: Version this data was fetched from. + app_tenant_id: Application tenant this data belongs to. + config_objects: Configuration objects and their entity data. + """ + + consumption_version: str + app_tenant_id: str + config_objects: list[ConfigObject] + + def get_config_object(self, config_object_id: str) -> ConfigObject | None: + """Return the config object with the given ID. + + Args: + config_object_id: Authored config object identifier. + + Returns: + Matching :class:`ConfigObject`, or ``None`` if not found. + """ + return next( + ( + co + for co in self.config_objects + if co.config_object_id == config_object_id + ), + None, + ) + + def get_entity_data( + self, config_object_id: str, entity_id: str + ) -> EntityData | None: + """Return entity data for the given config object and entity. + + Args: + config_object_id: Authored config object identifier. + entity_id: Authored entity identifier. + + Returns: + Matching :class:`EntityData`, or ``None`` if not found. + """ + co = self.get_config_object(config_object_id) + return co.get_entity(entity_id) if co is not None else None + + +# --------------------------------------------------------------------------- +# API error model +# --------------------------------------------------------------------------- + + +class ApiError(_FrozenModel): + """Error payload returned by the CBC API. + + Attributes: + code: Application-level error code. + message: Human-readable error message. + """ + + code: str + message: str + + @classmethod + def from_response(cls, response_body: bytes | None) -> "ApiError": + """Parse a CBC API error response body. + + Falls back to a generic ``UNKNOWN_ERROR`` when the body is absent or + unparseable. + + Args: + response_body: Raw HTTP response body. + + Returns: + Parsed :class:`ApiError`. + """ + if not response_body: + return cls(code="UNKNOWN_ERROR", message="No response body") + try: + data = json.loads(response_body.decode("utf-8")) + if isinstance(data, dict) and "error" in data: + return cls( + code=data["error"].get("code", "UNKNOWN_ERROR"), + message=data["error"].get("message", "Unknown error"), + ) + except (json.JSONDecodeError, UnicodeDecodeError): + pass + return cls( + code="UNKNOWN_ERROR", + message=response_body.decode("utf-8", errors="replace").strip(), + ) diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py new file mode 100644 index 00000000..9d3376eb --- /dev/null +++ b/src/sap_cloud_sdk/cbc/client.py @@ -0,0 +1,479 @@ +"""CBC client implementations for reading business configuration from CBC. + +This module provides: + +- :class:`CBCClient` — Protocol defining the client interface; use for type + annotations and test doubles. +- :class:`DefaultClient` — Production client using mTLS against the CBC service. +- :func:`create_client` — Thin factory over :class:`DefaultClient`. + +``base_url`` and ``app_tenant_id`` are supplied as callables, invoked on every +request. In a multi-tenant agent the CBC URL and the application tenant id both +vary per request (resolved from request-scoped context), so the client never +binds them at construction time. + +Quick start:: + + from sap_cloud_sdk import cbc + + cbc_client = cbc.create_client( + base_url=lambda: resolve_cbc_url(), + app_tenant_id=lambda: resolve_app_tenant_id(), + ssl_context=lambda: build_ssl_ctx(), + ) + config = cbc_client.get_configuration() +""" + +from __future__ import annotations + +import logging +import ssl +import threading +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Protocol + +import httpx + +from sap_cloud_sdk.cbc._models import ( + ApiError, + ConfigData, + ConfigObject, + ConfigObjectList, + ConsumptionVersions, + EntityContent, + EntityData, +) +from sap_cloud_sdk.cbc.exceptions import ( + CBCClientError, + CBCNetworkError, + CBCServerError, + HttpContext, +) +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics + +logger = logging.getLogger(__name__) + + +def _is_tls_failure(exc: BaseException) -> bool: + """Return ``True`` if an :class:`ssl.SSLError` appears in the cause chain. + + An expired or rotated mTLS client certificate is rejected by the server at + the TLS handshake, which httpx surfaces as a transport error (e.g. + ``httpx.ReadError``) wrapping ``httpcore.ReadError`` wrapping + ``ssl.SSLError``. The ``ssl.SSLError`` is not the top-level type, so walk + the ``__cause__`` / ``__context__`` chain instead of checking ``isinstance`` + on ``exc`` directly. ``seen`` guards against a cyclic chain. + """ + seen: set[int] = set() + cur: BaseException | None = exc + while cur is not None and id(cur) not in seen: + seen.add(id(cur)) + if isinstance(cur, ssl.SSLError): + return True + cur = cur.__cause__ or cur.__context__ + return False + + +# --------------------------------------------------------------------------- +# Public Protocol (interface for type annotations and test doubles) +# --------------------------------------------------------------------------- + + +class CBCClient(Protocol): + """Interface for reading business configuration from CBC. + + Implement this Protocol to substitute :class:`DefaultClient` with a test + double, offline stub, or alternative production client. + """ + + def get_consumption_versions(self) -> ConsumptionVersions: + """Return the available consumption versions for the configured tenant. + + A consumption version represents a snapshot of the business configuration + for an app tenant at a point in time. Use this to discover the active + version ID when you don't already have it. + """ + ... + + def get_configuration( + self, + consumption_version: str | None = None, + ) -> ConfigData: + """Return the business configuration for all entities in one call. + + When ``consumption_version`` is omitted, the latest version is resolved + automatically via :meth:`get_consumption_versions`. + """ + ... + + +# --------------------------------------------------------------------------- +# Internal config dataclass +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _ApiPaths: + """API path configuration for a :class:`DefaultClient` instance.""" + + configurations_path: str + + +# --------------------------------------------------------------------------- +# DefaultClient +# --------------------------------------------------------------------------- + + +class DefaultClient: + """CBC client implementation. + + Prefer :func:`create_client` over direct instantiation:: + + cbc_client = cbc.create_client( + base_url=lambda: resolve_cbc_url(), + app_tenant_id=lambda: resolve_app_tenant_id(), + ssl_context=lambda: build_ssl_ctx(), + ) + + Direct instantiation is supported for testing (inject a mock ``http_client``). + + Args: + base_url: Callable returning the CBC service base URL. Invoked on every + request — in a multi-tenant agent the URL comes from a request-scoped + Destination Fragment, so it is resolved per call. + app_tenant_id: Callable returning the application tenant identifier. + Invoked on every request and sent as the ``appTenantId`` query + parameter. + http_client: Optional pre-configured ``httpx.Client`` — takes full + precedence over ``ssl_context``. Use for testing. + ssl_context: Optional callable returning a freshly-built + :class:`ssl.SSLContext` with mTLS loaded. Resolved **once** at + construction, then re-invoked **only** on a TLS handshake failure to + pick up a rotated certificate (not per request). ``None`` for the + plain-HTTP / local-mock path. + """ + + def __init__( + self, + base_url: Callable[[], str], + app_tenant_id: Callable[[], str], + http_client: httpx.Client | None = None, + ssl_context: Callable[[], ssl.SSLContext] | None = None, + ) -> None: + self._base_url = base_url + self._app_tenant_id = app_tenant_id + self._config = _ApiPaths( + configurations_path="/configuration/v1", + ) + self._ssl_factory = ssl_context + self._lock = threading.Lock() + self._http_client = http_client or self._build_http_client() + + def _build_http_client(self) -> httpx.Client: + """Build an ``httpx.Client``, resolving the SSL context from the factory. + + ``verify`` is either the resolved mTLS :class:`ssl.SSLContext` or ``True`` + (httpx's default CA bundle) — never ``False``, so TLS verification is + always on. + """ + ctx = self._ssl_factory() if self._ssl_factory else None + return httpx.Client(verify=ctx if ctx is not None else True) + + def close(self) -> None: + """Close the underlying HTTP client and release connections.""" + self._http_client.close() + + def __enter__(self) -> "DefaultClient": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + def _resolve_app_tenant_id(self) -> str: + return self._app_tenant_id() + + # ------------------------------------------------------------------ + # Public API methods + # ------------------------------------------------------------------ + + @record_metrics(Module.CBC, Operation.CBC_GET_CONSUMPTION_VERSIONS) + def get_consumption_versions(self) -> ConsumptionVersions: + """Return available consumption versions for the configured tenant. + + Returns: + :class:`ConsumptionVersions` with all versions for the tenant. + + Raises: + CBCClientError: On 4xx responses. + CBCServerError: On 5xx responses. + CBCNetworkError: On connection failures. + """ + return self._get_consumption_versions( + self._base_url(), self._resolve_app_tenant_id() + ) + + def _get_consumption_versions( + self, base_url: str, app_tenant_id: str + ) -> ConsumptionVersions: + """Return the consumption versions, using already-resolved request values. + + Takes ``base_url`` / ``app_tenant_id`` as arguments so a caller that has + already resolved them (e.g. :meth:`get_configuration`) does not resolve + them a second time — resolution can hit the Destination Service, and a + second resolve could also disagree with the first if the request context + changed in between. + + Args: + base_url: Resolved CBC service base URL for this operation. + app_tenant_id: Application tenant identifier. + + Raises: + CBCClientError: On 4xx responses. + CBCServerError: On 5xx responses. + CBCNetworkError: On connection failures. + """ + url = self._configurations_url( + base_url, + f"/consumptionVersions?appTenantId={app_tenant_id}", + ) + return ConsumptionVersions.model_validate(self._request("GET", url).json()) + + def _get_configuration_objects( + self, base_url: str, app_tenant_id: str, consumption_version: str + ) -> ConfigObjectList: + """Return the config objects (with their entities) for the given version. + + The API returns config objects already grouped with their child entities, + so no client-side grouping is needed. + + Args: + base_url: Resolved CBC service base URL for this operation. + app_tenant_id: Application tenant identifier. + consumption_version: Consumption version ID. + + Returns: + :class:`ConfigObjectList` containing config objects and their entities. + + Raises: + CBCClientError: On 4xx responses. + CBCServerError: On 5xx responses. + CBCNetworkError: On connection failures. + """ + url = self._configurations_url( + base_url, + f"/consumptionVersions/{consumption_version}/configurationObjects" + f"?appTenantId={app_tenant_id}", + ) + return ConfigObjectList.model_validate(self._request("GET", url).json()) + + @record_metrics(Module.CBC, Operation.CBC_GET_CONFIGURATION) + def get_configuration( + self, + consumption_version: str | None = None, + ) -> ConfigData: + """Return the full business configuration for the configured tenant. + + Fetches the config objects and the data for every entity they contain, for + the specified consumption version. When ``consumption_version`` is omitted, + the latest version is resolved automatically via + :meth:`get_consumption_versions`. + + Args: + consumption_version: Consumption version ID. When ``None``, the + latest version is resolved via :meth:`get_consumption_versions`. + + Returns: + :class:`ConfigData` containing all entity data for the version. + + Raises: + CBCClientError: On 4xx responses, or when no consumption version exists + for the tenant and ``consumption_version`` was not provided. + CBCServerError: On 5xx responses. + CBCNetworkError: On connection failures. + """ + base_url = self._base_url() + app_tenant_id = self._resolve_app_tenant_id() + if consumption_version is None: + versions = self._get_consumption_versions(base_url, app_tenant_id) + latest = versions.latest() + if latest is None: + raise CBCClientError( + f"CBC returned no consumption version for tenant={app_tenant_id!r}." + ) + consumption_version = latest.version + + co_list = self._get_configuration_objects( + base_url, app_tenant_id, consumption_version + ) + config_objects = [ + ConfigObject( + config_object_id=entry.config_object_id, + entities=[ + self._fetch_entity_data( + base_url, + app_tenant_id, + consumption_version, + entry.config_object_id, + entity.entity_id, + ) + for entity in entry.entities + ], + ) + for entry in co_list.items + ] + return ConfigData( + consumption_version=consumption_version, + app_tenant_id=app_tenant_id, + config_objects=config_objects, + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _fetch_entity_data( + self, + base_url: str, + app_tenant_id: str, + consumption_version: str, + config_object_id: str, + entity_id: str, + ) -> EntityData: + url = self._configurations_url( + base_url, + f"/consumptionVersions/{consumption_version}/configurationObjects" + f"/{config_object_id}/entities/{entity_id}/data" + f"?appTenantId={app_tenant_id}", + ) + body = self._request("GET", url).json() + + api_meta = body.get("metadata", {}) if isinstance(body, dict) else {} + content = body.get("content", {}) if isinstance(body, dict) else {} + shape = body.get("contentShape") if isinstance(body, dict) else None + if shape == "OBJECT": + raw_data: list[dict[str, Any]] | dict[str, Any] = content.get("item", {}) + else: + # ARRAY / UNSPECIFIED / absent — items may be absent or empty. + raw_data = content.get("items", []) + + resolved_id = api_meta.get("entityName") or entity_id + return EntityData(entity_id=resolved_id, data=EntityContent(raw_data)) + + def _configurations_url(self, base_url: str, path: str = "") -> str: + base = base_url.rstrip("/") + return f"{base}{self._config.configurations_path}{path}" + + def _request( + self, + method: str, + url: str, + body: dict[str, Any] | None = None, + ) -> httpx.Response: + logger.debug("CBC %s %s", method, url) + try: + response = self._send(method, url, body) + except httpx.TransportError as exc: + # A TLS handshake failure (e.g. an expired/rotated client cert) is + # recoverable: rebuild the client from a fresh SSL context once and + # retry this one request. Non-TLS transport errors and all other + # request errors fall through to the terminal handler below. + if self._ssl_factory is not None and _is_tls_failure(exc): + logger.info("CBC TLS failure; reloading certificate and retrying") + self._rebuild_http_client() + try: + response = self._send(method, url, body) + except httpx.RequestError as retry_exc: + raise self._network_error(method, url, retry_exc) from retry_exc + else: + raise self._network_error(method, url, exc) from exc + except httpx.RequestError as exc: + raise self._network_error(method, url, exc) from exc + + if response.status_code >= 400: + ctx = HttpContext( + status_code=response.status_code, + request_method=method, + request_url=url, + ) + error = ApiError.from_response(response.content) + exc_class = ( + CBCServerError if response.status_code >= 500 else CBCClientError + ) + raise exc_class(error.message, code=error.code, http_context=ctx) + + return response + + def _send( + self, method: str, url: str, body: dict[str, Any] | None + ) -> httpx.Response: + """Perform one HTTP request via the underlying client. + + A thin wrapper over ``self._http_client.request`` with no error translation — + callers map failures to CBC exceptions and own any retry logic. + """ + return self._http_client.request( + method=method, + url=url, + headers={}, + json=body, + timeout=30.0, + ) + + def _rebuild_http_client(self) -> None: + """Rebuild the HTTP client from a fresh SSL context, under a lock. + + Called on a TLS failure to pick up a rotated certificate. Guards against + a thundering herd — if another thread already rebuilt while this one + waited on the lock, reuse that client instead of rebuilding again. + + The new client is built into a local before it replaces ``self._http_client``, + so a failing factory (which raises :class:`CBCConfigError` — the cert + loader wraps every load-time failure) leaves ``self._http_client`` on the prior + working context and the error propagates to the caller. + """ + with self._lock: + previous = self._http_client + new_client = self._build_http_client() + self._http_client = new_client + previous.close() + + def _network_error(self, method: str, url: str, exc: Exception) -> CBCNetworkError: + return CBCNetworkError( + f"Network error calling CBC: {exc}", + http_context=HttpContext( + status_code=-1, request_method=method, request_url=url + ), + ) + + +# --------------------------------------------------------------------------- +# Factory function +# --------------------------------------------------------------------------- + + +def create_client( + *, + base_url: Callable[[], str], + app_tenant_id: Callable[[], str], + ssl_context: Callable[[], ssl.SSLContext] | None = None, +) -> CBCClient: + """Create a :class:`DefaultClient`. + + Args: + base_url: Callable returning the CBC service base URL, invoked per request. + app_tenant_id: Callable returning the application tenant id, invoked per + request and sent as the ``appTenantId`` query parameter. + ssl_context: Optional callable returning a freshly-built + :class:`ssl.SSLContext` with mTLS loaded. Resolved once at + construction, then re-invoked only on a TLS handshake failure to + reload a rotated certificate. + + Returns: + A configured :class:`DefaultClient`. + """ + return DefaultClient( + base_url=base_url, + app_tenant_id=app_tenant_id, + ssl_context=ssl_context, + ) diff --git a/src/sap_cloud_sdk/cbc/client_adapter.py b/src/sap_cloud_sdk/cbc/client_adapter.py new file mode 100644 index 00000000..f1b533ed --- /dev/null +++ b/src/sap_cloud_sdk/cbc/client_adapter.py @@ -0,0 +1,319 @@ +"""Platform adapter for the CBC (Central Business Configuration) module. + +This is a thin convenience layer over the generic client in +:mod:`sap_cloud_sdk.cbc.client`. It encodes the SAP application-platform +provisioning convention, where the pieces a CBC call needs are standard: + +- the app's own provider-level **Destination**, holding the mTLS certificate; +- the tenant-mapping **Fragment**, carrying the CBC URL (written by the + platform during tenant provisioning). + +The adapter adds **no** capability to the core client — it only supplies the +``base_url`` / ``app_tenant_id`` resolvers and the mTLS ``ssl.SSLContext`` from +those conventions, then delegates to +:func:`~sap_cloud_sdk.cbc.client.create_client`. Agents are the typical +consumer, but any app that follows the same provisioning convention can use it. +Every default is overridable. + +Two ContextVars are **defined here and owned by the SDK**; the app populates +them — e.g. from the IAS JWT ``app_tid`` claim and the ``dwc-subdomain`` +header, though the SDK does not mandate the source. The SDK deliberately does +not know *how* those values are obtained — authentication and the request +pipeline stay with the app. + +The mTLS certificate is fetched from the Destination Service when the client is +built, and **reloaded automatically** if a request later fails the TLS handshake +(e.g. after the certificate rotates), so a long-lived client recovers without +being recreated. + +Quick start:: + + from sap_cloud_sdk import cbc + + cbc_client = cbc.create_agent_client() # once, at startup + + # per request: + cbc.app_tenant_id_var.set(parse_token(bearer).app_tid) + cbc.tenant_subdomain_var.set(request.headers["dwc-subdomain"]) +""" + +from __future__ import annotations + +import os +import ssl +from contextvars import ContextVar + +from sap_cloud_sdk.cbc.client import CBCClient, create_client +from sap_cloud_sdk.cbc.config import CBCDestinationConfig +from sap_cloud_sdk.cbc.exceptions import CBCConfigError + +# --------------------------------------------------------------------------- +# SDK-owned context +# --------------------------------------------------------------------------- + +#: Application tenant id for the current context (typically the IAS ``app_tid`` +#: claim, but the SDK does not mandate the source). +app_tenant_id_var: ContextVar[str] = ContextVar("cbc_app_tenant_id", default="") + +#: Tenant subdomain for the current context (typically the ``dwc-subdomain`` +#: header, but the SDK does not mandate the source). +tenant_subdomain_var: ContextVar[str] = ContextVar("cbc_tenant_subdomain", default="") + +# --------------------------------------------------------------------------- +# Platform conventions +# --------------------------------------------------------------------------- +# The fragment name and certificate name below follow the application-platform +# convention; they are not arbitrary. Override via the args / env vars if your +# setup differs. + +#: Prefix of the Destination Fragment that maps a subdomain to its CBC tenant +#: (platform convention). +CBC_FRAGMENT_PREFIX = "CBC_TenantMapping_" + +#: Template for the app's own provider-level mTLS certificate name +#: (platform convention). +_CERT_NAME_TEMPLATE = "sap-managed-runtime-ias-{landscape}.pem" + +# Env overrides — the defaults cover the common case; set these only to override. + +#: The ``instance`` passed to the destination ``create_fragment_client`` / +#: ``create_certificate_client`` (used for secret resolution in cloud mode, +#: defaults to "default"); it is not the individual destination / cert / +#: fragment, which are selected by name. +ENV_DESTINATION_INSTANCE = "CLOUD_SDK_CBC_DESTINATION_INSTANCE" + +#: Explicit certificate name, overriding the landscape-derived default. +ENV_CERT_NAME = "CLOUD_SDK_CBC_CERTIFICATE_NAME" + +#: Password for the certificate keystore, if encrypted (default: none). +ENV_P12_PASSWORD = "CLOUD_SDK_CBC_P12_PASSWORD" + +#: Platform landscape, used to derive the default certificate name. +ENV_LANDSCAPE = "APPFND_CONHOS_LANDSCAPE" + + +def _default_cert_name() -> str: + """Return the name of the app's provider-level mTLS certificate. + + This is the certificate the app presents to CBC for the mTLS handshake; its + name follows the platform convention :data:`_CERT_NAME_TEMPLATE`, with the + landscape filled in from :data:`ENV_LANDSCAPE`. + + Raises: + CBCConfigError: If :data:`ENV_LANDSCAPE` is not set, so the name cannot + be derived. + """ + landscape = os.environ.get(ENV_LANDSCAPE) + if not landscape: + raise CBCConfigError( + f"Cannot derive the CBC certificate name: {ENV_LANDSCAPE} is not set. " + f"Set it, or pass cbc_cert_name to create_agent_client()." + ) + return _CERT_NAME_TEMPLATE.format(landscape=landscape) + + +def _cert_password_from_env() -> bytes | None: + """Return the cert keystore password from :data:`ENV_P12_PASSWORD`, or ``None``.""" + raw = os.environ.get(ENV_P12_PASSWORD) + return raw.encode() if raw else None + + +# --------------------------------------------------------------------------- +# Default resolvers +# --------------------------------------------------------------------------- + + +def resolve_app_tenant_id() -> str: + """Return the application tenant id from :data:`app_tenant_id_var`. + + Public platform resolver — the default :func:`create_agent_client` wires it + as the ``app_tenant_id`` callable. Also usable directly to compose + :func:`~sap_cloud_sdk.cbc.client.create_client` when overriding only *some* + of the platform defaults (e.g. keep this + :func:`resolve_base_url` but + supply your own ``ssl_context``). + + The application tenant id identifies the subscriber tenant to CBC — e.g. the + subscriber's subaccount id, carried in the IAS JWT ``app_tid`` claim. + + Raises: + CBCConfigError: If the ContextVar is empty. + """ + app_tid = app_tenant_id_var.get() + if not app_tid: + raise CBCConfigError( + "cbc_app_tenant_id ContextVar is empty — set app_tenant_id_var from " + "the IAS app_tid claim." + ) + return app_tid + + +def resolve_base_url(destination_instance: str) -> str: + """Return the CBC base URL for the current tenant. + + Public platform resolver — the default :func:`create_agent_client` wires it + (as ``lambda: resolve_base_url(destination_instance)``) into the + ``base_url`` callable. Also usable directly to compose + :func:`~sap_cloud_sdk.cbc.client.create_client` when overriding only *some* + of the platform defaults. + + Reads :data:`tenant_subdomain_var` and :data:`app_tenant_id_var`, lists the + tenant-mapping Fragments in the subscriber's subaccount, finds the one whose + ``appTenantId`` property matches, and returns its ``cbcUrl`` verbatim. + + The fragments are named ``CBC_TenantMapping_``, so they cannot be + fetched directly by subdomain; the subaccount is listed and matched on the + ``appTenantId`` property instead. (Once the fragment is keyed by subdomain + upstream, this becomes a single direct ``get_subaccount_fragment`` lookup.) + + Args: + destination_instance: The ``instance`` passed to the destination + ``create_fragment_client`` (selects which Destination Service binding + to read, used for secret resolution in cloud mode). + + Raises: + CBCConfigError: If either ContextVar is empty, no matching fragment is + found, or the matched fragment has no ``cbcUrl`` property. + """ + from sap_cloud_sdk.destination import create_fragment_client + from sap_cloud_sdk.destination.exceptions import DestinationError + + subdomain = tenant_subdomain_var.get() + if not subdomain: + raise CBCConfigError( + "cbc_tenant_subdomain ContextVar is empty — set tenant_subdomain_var " + "from the dwc-subdomain header." + ) + app_tenant_id = resolve_app_tenant_id() + + try: + client = create_fragment_client(instance=destination_instance) + fragments = client.list_subaccount_fragments(tenant=subdomain) + except DestinationError as exc: + raise CBCConfigError( + f"Could not resolve the CBC URL: listing tenant-mapping fragments " + f"failed for subdomain={subdomain!r} (appTenantId={app_tenant_id!r}): {exc}" + ) from exc + fragment = next( + ( + f + for f in fragments + if f.name.startswith(CBC_FRAGMENT_PREFIX) + and f.properties.get("appTenantId") == app_tenant_id + ), + None, + ) + if fragment is None: + raise CBCConfigError( + f"No CBC mapping fragment for appTenantId={app_tenant_id!r} in " + f"subdomain={subdomain!r} (looked for {CBC_FRAGMENT_PREFIX}* fragments)." + ) + cbc_url = fragment.properties.get("cbcUrl") + if not cbc_url: + raise CBCConfigError( + f"CBC mapping fragment {fragment.name!r} has no 'cbcUrl' property." + ) + return cbc_url # cbcTid already baked in — used verbatim + + +def load_ssl_context( + destination_instance: str, cert_name: str, p12_password: bytes | None +) -> ssl.SSLContext: + """Fetch the provider certificate and load it into an :class:`ssl.SSLContext`. + + Public platform resolver — the default :func:`create_agent_client` wires it + (as ``lambda: load_ssl_context(...)``) into the ``ssl_context`` factory. Also + usable directly to compose :func:`~sap_cloud_sdk.cbc.client.create_client` + when overriding only *some* of the platform defaults. + + Args: + destination_instance: The ``instance`` passed to the destination + ``create_certificate_client`` (selects which Destination Service + binding to read, used for secret resolution in cloud mode). + cert_name: Name of the subaccount certificate to fetch. + p12_password: Certificate keystore password, or ``None`` if unencrypted. + + Raises: + CBCConfigError: If the certificate cannot be fetched or is not found. + """ + from sap_cloud_sdk.destination import AccessStrategy, create_certificate_client + from sap_cloud_sdk.destination._cert_loader import _load_pem + from sap_cloud_sdk.destination.exceptions import DestinationError + + try: + cert = create_certificate_client( + instance=destination_instance + ).get_subaccount_certificate( + cert_name, access_strategy=AccessStrategy.PROVIDER_ONLY + ) + except DestinationError as exc: + raise CBCConfigError( + f"Could not fetch the mTLS certificate {cert_name!r} from Destination " + f"Service instance {destination_instance!r}: {exc}" + ) from exc + if cert is None: + raise CBCConfigError( + f"Subaccount certificate {cert_name!r} not found in Destination " + f"Service instance {destination_instance!r}." + ) + return _load_pem(cert.content, p12_password, cert.name) + + +# --------------------------------------------------------------------------- +# Platform constructor +# --------------------------------------------------------------------------- + + +def create_agent_client( + *, + config: CBCDestinationConfig | None = None, +) -> CBCClient: + """Create a CBC client wired for the application platform. + + Resolves ``base_url`` and ``app_tenant_id`` from the two SDK-owned + ContextVars (:data:`app_tenant_id_var`, :data:`tenant_subdomain_var`) each + time a request is made, and loads the mTLS certificate from the Destination + Service. The certificate is reloaded automatically on a TLS handshake + failure, so the client recovers from certificate rotation without being + recreated. + + To inject a pre-built :class:`ssl.SSLContext` (startup injection or tests), + use the generic :func:`~sap_cloud_sdk.cbc.client.create_client` directly. + + Args: + config: Destination-Service inputs (which instance, certificate, and + keystore password to read). Each unset field falls back to its + ``CLOUD_SDK_CBC_*`` env var, then to a platform default — see + :class:`~sap_cloud_sdk.cbc.config.CBCDestinationConfig`. Omit it + entirely to rely on the env vars / defaults for everything. + + Returns: + A configured CBC client. + + Raises: + CBCConfigError: If the certificate name cannot be resolved, or — when a + request is made — if the certificate cannot be fetched, a ContextVar + is empty, or the tenant-mapping fragment is missing. + """ + if config is None: + config = CBCDestinationConfig() + + destination_instance = config.destination_instance + if destination_instance is None: + destination_instance = os.environ.get(ENV_DESTINATION_INSTANCE, "default") + + resolved_cert_name = ( + config.cbc_cert_name or os.environ.get(ENV_CERT_NAME) or _default_cert_name() + ) + resolved_password = ( + config.p12_password + if config.p12_password is not None + else _cert_password_from_env() + ) + + return create_client( + base_url=lambda: resolve_base_url(destination_instance), + app_tenant_id=resolve_app_tenant_id, + ssl_context=lambda: load_ssl_context( + destination_instance, resolved_cert_name, resolved_password + ), + ) diff --git a/src/sap_cloud_sdk/cbc/config.py b/src/sap_cloud_sdk/cbc/config.py new file mode 100644 index 00000000..fd9d295d --- /dev/null +++ b/src/sap_cloud_sdk/cbc/config.py @@ -0,0 +1,30 @@ +"""Configuration objects for the CBC (Central Business Configuration) module.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class CBCDestinationConfig: + """Destination-Service inputs for :func:`create_agent_client`. + + All three fields select *what to read* from the Destination Service when the + platform adapter builds a client. Each falls back to its ``CLOUD_SDK_CBC_*`` + env var, then to a platform default, when left ``None``. + + Attributes: + destination_instance: The ``instance`` passed to the destination + ``create_fragment_client`` / ``create_certificate_client`` (used for + secret resolution in cloud mode). Falls back to the + ``CLOUD_SDK_CBC_DESTINATION_INSTANCE`` env var, else ``"default"``. + cbc_cert_name: Name of the app's mTLS certificate. Falls back to the + ``CLOUD_SDK_CBC_CERTIFICATE_NAME`` env var, else derived from the + platform landscape (``APPFND_CONHOS_LANDSCAPE``). + p12_password: Certificate keystore password. Falls back to the + ``CLOUD_SDK_CBC_P12_PASSWORD`` env var, else ``None``. + """ + + destination_instance: str | None = None + cbc_cert_name: str | None = None + p12_password: bytes | None = None diff --git a/src/sap_cloud_sdk/cbc/exceptions.py b/src/sap_cloud_sdk/cbc/exceptions.py new file mode 100644 index 00000000..30b552ec --- /dev/null +++ b/src/sap_cloud_sdk/cbc/exceptions.py @@ -0,0 +1,89 @@ +"""Exception classes for the CBC (Central Business Configuration) module.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +class CBCError(Exception): + """Base exception for all CBC module errors.""" + + pass + + +class CBCConfigError(CBCError): + """Raised when CBC configuration is missing or unusable. + + Raised when no configuration can be resolved from the environment, or when + configuration is partially set (e.g. only one of the cert/key env vars is + provided, or a path env var points to a non-existent file). + """ + + pass + + +@dataclass(frozen=True) +class HttpContext: + """Context attached to HTTP exceptions. + + Attributes: + status_code: HTTP status code, or ``-1`` for network-level failures. + request_method: HTTP verb (GET, POST, PATCH, …). + request_url: Full request URL. + """ + + status_code: int + request_method: str + request_url: str + + +class CBCHttpError(CBCError): + """Raised for HTTP errors communicating with the CBC service. + + Attributes: + code: Application-level error code from the CBC API response, if available. + http_context: Request/response context. + """ + + def __init__( + self, + message: str, + code: str | None = None, + http_context: HttpContext | None = None, + ) -> None: + super().__init__(message) + self.message = message + self.code = code + self.http_context = http_context + + def __str__(self) -> str: + parts = [super().__str__()] + if self.code: + parts.append(f"code={self.code}") + if self.http_context: + parts.append(f"http_context={self.http_context}") + return " | ".join(parts) + + +class CBCClientError(CBCHttpError): + """Raised for 4xx responses from the CBC API.""" + + pass + + +class CBCServerError(CBCHttpError): + """Raised for 5xx responses from the CBC API.""" + + pass + + +class CBCNetworkError(CBCError): + """Raised for network-level failures (DNS, connection refused, timeouts).""" + + def __init__( + self, + message: str, + http_context: HttpContext | None = None, + ) -> None: + super().__init__(message) + self.http_context = http_context diff --git a/src/sap_cloud_sdk/cbc/py.typed b/src/sap_cloud_sdk/cbc/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/sap_cloud_sdk/cbc/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md new file mode 100644 index 00000000..bc6f9623 --- /dev/null +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -0,0 +1,262 @@ +# SAP Cloud SDK — CBC module + +Typed Python client for reading tenant-specific business configuration from +SAP Central Business Configuration (CBC). + +## Concepts + +**Consumption version** — a snapshot of the business configuration for one app +tenant at a point in time. An app tenant usually has one active version. + +**Configuration object** — a logical grouping of related configuration, e.g. +`payment-config` or `agent-config`. Authored by the application or agent team +and shipped as part of a reference content package. + +**Entity** — one slice of configuration within a config object. A config +object has one or more related entities; each entity has a stable authored `id` +(e.g. `payment-mode`) and a JSON schema defining its data shape. + +**Entity data** — the configuration content for an entity. + +## Setup + +`base_url` and `app_tenant_id` are supplied as callables, invoked on every +request. In a multi-tenant agent both the CBC URL and the application tenant id +vary per request (resolved from request-scoped context), so the client never +binds them at construction time. + +```python +from sap_cloud_sdk import cbc + +cbc_client = cbc.create_client( + base_url=lambda: resolve_cbc_url(), + app_tenant_id=lambda: resolve_app_tenant_id(), + ssl_context=lambda: build_ssl_ctx(), +) +``` + +`ssl_context` is a callable returning a fresh `ssl.SSLContext`. It is resolved +once when the client is built and re-invoked only if a request fails the TLS +handshake, so a rotated certificate is picked up automatically on the next call. + +Apps or agents running on the SAP application foundation platform can use `create_agent_client` +instead — it supplies both resolvers and the mTLS context from the platform's +provisioning conventions. See [Platform setup](#platform-setup). + +## Reading configuration + +### Fetch everything in one call + +```python +# latest version resolved automatically +config = cbc_client.get_configuration() + +# pin a specific version +config = cbc_client.get_configuration(consumption_version="a0392d4f-72a9-...") + +# pick from the list +versions = cbc_client.get_consumption_versions() +cv = versions.latest() # or versions.items[0], or your own selection logic +config = cbc_client.get_configuration(consumption_version=cv.version) +``` + +### ConfigData structure + +``` +ConfigData +├── consumption_version: str # e.g. "a0392d4f-72a9-..." +├── app_tenant_id: str +└── config_objects: list[ConfigObject] + ├── ConfigObject + │ ├── config_object_id: str # e.g. "payment-config" + │ └── entities: list[EntityData] + │ └── EntityData + │ ├── entity_id: str # e.g. "payment-mode" + │ └── data: EntityContent # .as_list() or .as_object() + └── ConfigObject + ├── config_object_id: str # e.g. "agent-config" + └── entities: list[EntityData] + └── EntityData + ├── entity_id: str # e.g. "contact" + └── data: EntityContent # .as_list() or .as_object() +``` + +### Iterate all config objects and entities + +```python +for co in config.config_objects: + for ed in co.entities: + print(f"{co.config_object_id}/{ed.entity_id}") +``` + +### Look up a specific entity + +```python +# All entities for one config object +payment = config.get_config_object("payment-config") # ConfigObject | None +if payment: + modes = payment.get_entity("payment-mode") # EntityData | None + if modes: + for row in modes.data.as_list(): + print(row["paymentModeCode"], row["name"]) +``` + +`modes.data.as_list()` returns `list[dict]` and raises `ValueError` if the data is not a list. +`modes.data.as_object()` returns `dict` and raises `ValueError` if the data is not a dict. + +If you don't know the shape in advance, check first with `modes.data.is_list()` / +`modes.data.is_object()`, or call `modes.data.value()` to get the raw `list[dict] | dict` +without any shape assertion: + +```python +if modes.data.is_list(): + rows = modes.data.as_list() +else: + settings = modes.data.as_object() +``` + +```python +# Shortcut — config object + entity in one step +modes = config.get_entity_data("payment-config", "payment-mode") # EntityData | None +``` + +```python +# Unmarshal into your own class +modes_list = [PaymentMode(**row) for row in modes.data.as_list()] +policy = PolicyConfig(**policy_entity.data.as_object()) +``` + + +## Error handling + +| Exception | When | +|---|---| +| `CBCClientError` | 4xx from CBC (e.g. tenant not found) | +| `CBCServerError` | 5xx from CBC | +| `CBCNetworkError` | connection failure | +| `CBCConfigError` | platform adapter cannot resolve the tenant mapping or certificate | + +```python +from sap_cloud_sdk import cbc + +try: + config = cbc_client.get_configuration() +except cbc.CBCClientError as e: + print(e.code, e.message) # e.g. "NOT_FOUND", "Tenant unknown" +except cbc.CBCNetworkError: + ... # retry / circuit-break +``` + +## Platform setup + +Apps running on the SAP application platform can skip wiring the two resolvers by +hand. `create_agent_client` supplies them from the platform's provisioning +conventions: it reads the application tenant id and tenant subdomain from two +SDK-owned `ContextVar`s, resolves the CBC URL from the tenant's Destination +Fragment, and loads the mTLS certificate from the Destination Service. + +The two artifacts it depends on follow the platform convention: + +- the app's own provider-level **Destination**, holding the mTLS certificate; +- the tenant-mapping **Fragment**, carrying the CBC URL (written by the platform + during tenant provisioning). + +Populate the two ContextVars from your request context — the SDK owns the vars, +your app owns *how* they are filled (e.g. from the IAS JWT `app_tid` claim +and the `dwc-subdomain` header, though the SDK does not mandate the source). + +```python +from sap_cloud_sdk import cbc + +# once, at startup — the mTLS context is built here +cbc_client = cbc.create_agent_client() + +# per request +cbc.app_tenant_id_var.set(parse_token(bearer).app_tid) # "5649a2cf-..." +cbc.tenant_subdomain_var.set(request.headers["dwc-subdomain"]) # "subscriber-abc" +``` + +The CBC URL comes from the `cbcUrl` property of the tenant-mapping fragment. The +adapter lists the `CBC_TenantMapping_*` fragments in the subscriber's subaccount +and picks the one whose `appTenantId` property matches; the `cbcUrl` is used +verbatim. The certificate is the app's own provider-level mTLS certificate, +fetched from the Destination Service. + +**Certificate rotation.** The mTLS certificate is loaded from the Destination +Service and reloaded automatically when a request fails the TLS handshake (as +happens once a certificate has rotated or expired): the client rebuilds its +mTLS context from a freshly-fetched certificate and retries the request once. A +long-lived `create_agent_client()` singleton therefore recovers from rotation on +its own — no need to recreate it. + +### Platform env overrides + +Defaults cover the common case; override via env when needed: + +| Variable | Default | Description | +|---|---|---| +| `APPFND_CONHOS_LANDSCAPE` | (platform-provided) | Landscape used to derive the certificate name `sap-managed-runtime-ias-{landscape}.pem` | +| `CLOUD_SDK_CBC_CERTIFICATE_NAME` | landscape-derived | Explicit certificate name, overriding the landscape derivation | +| `CLOUD_SDK_CBC_DESTINATION_INSTANCE` | `default` | The `instance` passed to the destination `create_fragment_client` / `create_certificate_client` (used for secret resolution in cloud mode) | +| `CLOUD_SDK_CBC_P12_PASSWORD` | (none) | Password for the certificate keystore, if encrypted | + +The same three fields can be set in code via `cbc.CBCDestinationConfig`, passed as +`create_agent_client(config=...)`; a value set on the config wins over its env var, +and any field left unset falls back to the env var, then the default. + +```python +from sap_cloud_sdk import cbc + +cbc_client = cbc.create_agent_client( + config=cbc.CBCDestinationConfig(cbc_cert_name="my-cert.pem"), +) +``` + + +### Overriding one axis + +`create_agent_client` is a fixed preset: it wires all three inputs — `base_url`, +`app_tenant_id`, and the mTLS `ssl_context` — from the platform conventions. To +keep *most* of that but override a single axis (say, supply your own mTLS context +from a vault while keeping the fragment-based `base_url` and the ContextVar +`app_tenant_id`), compose the generic `create_client` with the public platform +resolvers: + +```python +from sap_cloud_sdk import cbc + +client = cbc.create_client( + base_url=lambda: cbc.resolve_base_url("default"), + app_tenant_id=cbc.resolve_app_tenant_id, + ssl_context=lambda: my_ctx, # your own mTLS, still reloaded on TLS failure +) +``` + +`resolve_base_url(destination_instance)`, `resolve_app_tenant_id`, and +`load_ssl_context(destination_instance, cert_name, p12_password)` are the same +resolvers the preset uses; mix in your own callable for the axis you want to +control. The `ssl_context` callable you pass still participates in the automatic +reload-on-TLS-failure rotation described above. + +## Using a test double + +`CBCClient` is a `Protocol` — implement it directly in tests: + +```python +from sap_cloud_sdk import cbc + + +class StubCBCClient: + def get_consumption_versions(self): ... + def get_configuration(self, consumption_version=None): + return cbc.ConfigData( + consumption_version="cv1", + app_tenant_id="app-t1", + config_objects=[], + ) + + +def test_my_service(): + service = MyService(cbc_client=StubCBCClient()) + ... +``` diff --git a/src/sap_cloud_sdk/core/telemetry/module.py b/src/sap_cloud_sdk/core/telemetry/module.py index c3ceb80a..840e6ac5 100644 --- a/src/sap_cloud_sdk/core/telemetry/module.py +++ b/src/sap_cloud_sdk/core/telemetry/module.py @@ -7,6 +7,7 @@ class Module(str, Enum): """SDK module identifiers for telemetry.""" ADMS = "adms" + CBC = "cbc" AGENT_MEMORY = "agent_memory" AGENTGATEWAY = "agentgateway" AICORE = "aicore" diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index eb8a018c..249108b7 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -139,6 +139,10 @@ class Operation(str, Enum): ADMS_CONFIG_GET_APP_TENANT = "config_get_app_tenant" ADMS_CONFIG_DELETE_APP_TENANT = "config_delete_app_tenant" + # CBC Operations + CBC_GET_CONSUMPTION_VERSIONS = "get_consumption_versions" + CBC_GET_CONFIGURATION = "get_configuration" + # Bootstrap Operations BOOTSTRAP = "bootstrap" diff --git a/tests/cbc/__init__.py b/tests/cbc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cbc/integration/__init__.py b/tests/cbc/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cbc/integration/cbc.feature b/tests/cbc/integration/cbc.feature new file mode 100644 index 00000000..f3da6f35 --- /dev/null +++ b/tests/cbc/integration/cbc.feature @@ -0,0 +1,30 @@ +Feature: CBC (Central Business Configuration) Integration + + Background: + Given a configured CBC client and tenant context + + # ── Consumption Versions ───────────────────────────────────────────────────── + + Scenario: Fetch consumption versions returns at least one version + When I call get_consumption_versions + Then the result should contain at least one version + + Scenario: Latest consumption version is non-empty + When I call get_consumption_versions + Then the latest version should have a non-empty version string + + # ── Configuration ──────────────────────────────────────────────────────────── + + Scenario: Fetch full configuration returns ConfigData + When I call get_configuration + Then the result should be a ConfigData with a non-empty consumption_version + And the app_tenant_id should match the configured tenant + + Scenario: Full configuration contains at least one config object + When I call get_configuration + Then the result should contain at least one config object + + Scenario: Every entity within each config object has an entity_id and data + When I call get_configuration + Then every entity should have a non-empty entity_id + And every entity data should be accessible as a list or object diff --git a/tests/cbc/integration/conftest.py b/tests/cbc/integration/conftest.py new file mode 100644 index 00000000..7bea7835 --- /dev/null +++ b/tests/cbc/integration/conftest.py @@ -0,0 +1,64 @@ +"""Pytest fixtures for CBC integration tests. + +Tests target a real or mock CBC server. Configuration is read from env vars: + + CLOUD_SDK_CBC_URL CBC service base URL (required) + CLOUD_SDK_CBC_APP_TENANT_ID Application tenant ID (required) + CLOUD_SDK_CBC_CERT_PATH Client certificate PEM path (optional, mTLS) + CLOUD_SDK_CBC_KEY_PATH Client private key PEM path (optional, mTLS) + +When any required variable is missing, integration tests are skipped. The cert +and key paths are optional — supply both to test against an mTLS server; omit +them for a plain-HTTP mock. +""" + +from __future__ import annotations + +import os +import ssl + +import pytest + +from sap_cloud_sdk.cbc import CBCClient, create_client + +ENV_URL = "CLOUD_SDK_CBC_URL" +ENV_APP_TENANT_ID = "CLOUD_SDK_CBC_APP_TENANT_ID" +ENV_CERT_PATH = "CLOUD_SDK_CBC_CERT_PATH" +ENV_KEY_PATH = "CLOUD_SDK_CBC_KEY_PATH" + + +def _require_app_tenant_id() -> str: + app_tid = os.environ.get(ENV_APP_TENANT_ID) + if not app_tid: + pytest.skip(f"CBC integration tests skipped — set {ENV_APP_TENANT_ID}.") + return app_tid + + +def _build_ssl_context() -> ssl.SSLContext | None: + """Build an mTLS context from the cert/key path env vars, or None if unset.""" + cert_path = os.environ.get(ENV_CERT_PATH) + key_path = os.environ.get(ENV_KEY_PATH) + if not cert_path or not key_path: + return None + ctx = ssl.create_default_context() + ctx.load_cert_chain(certfile=cert_path, keyfile=key_path) + return ctx + + +@pytest.fixture(scope="session") +def cbc_app_tenant_id() -> str: + return _require_app_tenant_id() + + +@pytest.fixture(scope="session") +def cbc_client() -> CBCClient: + app_tid = _require_app_tenant_id() + url = os.environ.get(ENV_URL) + if not url: + pytest.skip(f"CBC integration tests skipped — set {ENV_URL}.") + ctx = _build_ssl_context() + return create_client( + base_url=lambda: url, + app_tenant_id=lambda: app_tid, + ssl_context=(lambda: ctx) if ctx is not None else None, + ) diff --git a/tests/cbc/integration/test_e2e_bdd.py b/tests/cbc/integration/test_e2e_bdd.py new file mode 100644 index 00000000..a6fd4642 --- /dev/null +++ b/tests/cbc/integration/test_e2e_bdd.py @@ -0,0 +1,131 @@ +"""BDD integration tests for the CBC (Central Business Configuration) module. + +Run against a real or mock CBC server:: + + CLOUD_SDK_CBC_URL=http://localhost:8001 \\ + CLOUD_SDK_CBC_APP_TENANT_ID=my-app-tenant \\ + pytest tests/cbc/integration +""" + +from __future__ import annotations + +import pytest +from pytest_bdd import given, scenario, then, when + +from sap_cloud_sdk.cbc import ConfigData, ConsumptionVersions +from sap_cloud_sdk.cbc.client import DefaultClient + +pytestmark = pytest.mark.integration + + +# -- Shared step state --------------------------------------------------------- + + +@pytest.fixture +def ctx() -> dict: + return {} + + +# -- Scenarios ----------------------------------------------------------------- + + +@scenario("cbc.feature", "Fetch consumption versions returns at least one version") +def test_consumption_versions_non_empty(): + pass + + +@scenario("cbc.feature", "Latest consumption version is non-empty") +def test_latest_version_non_empty(): + pass + + +@scenario("cbc.feature", "Fetch full configuration returns ConfigData") +def test_get_configuration_returns_config_data(): + pass + + +@scenario("cbc.feature", "Full configuration contains at least one config object") +def test_configuration_has_config_objects(): + pass + + +@scenario( + "cbc.feature", "Every entity within each config object has an entity_id and data" +) +def test_every_entity_has_id_and_data(): + pass + + +# -- Steps --------------------------------------------------------------------- + + +@given("a configured CBC client and tenant context") +def cbc_context(cbc_client: DefaultClient, cbc_app_tenant_id: str): + pass + + +@when("I call get_consumption_versions") +def call_get_consumption_versions(ctx: dict, cbc_client: DefaultClient): + ctx["versions"] = cbc_client.get_consumption_versions() + + +@when("I call get_configuration") +def call_get_configuration(ctx: dict, cbc_client: DefaultClient): + ctx["config"] = cbc_client.get_configuration() + + +@then("the result should contain at least one version") +def assert_versions_non_empty(ctx: dict): + versions: ConsumptionVersions = ctx["versions"] + assert len(versions.items) >= 1 + + +@then("the latest version should have a non-empty version string") +def assert_latest_version_non_empty(ctx: dict): + versions: ConsumptionVersions = ctx["versions"] + latest = versions.latest() + assert latest is not None + assert latest.version + + +@then("the result should be a ConfigData with a non-empty consumption_version") +def assert_config_data_type(ctx: dict): + config: ConfigData = ctx["config"] + assert isinstance(config, ConfigData) + assert config.consumption_version + + +@then("the app_tenant_id should match the configured tenant") +def assert_app_tenant_id(ctx: dict, cbc_app_tenant_id: str): + config: ConfigData = ctx["config"] + assert config.app_tenant_id == cbc_app_tenant_id + + +@then("the result should contain at least one config object") +def assert_config_objects_non_empty(ctx: dict): + config: ConfigData = ctx["config"] + assert len(config.config_objects) >= 1 + + +@then("every entity should have a non-empty entity_id") +def assert_entity_ids(ctx: dict): + config: ConfigData = ctx["config"] + for co in config.config_objects: + for ed in co.entities: + assert ed.entity_id, ( + f"entity_id missing in config_object={co.config_object_id!r}" + ) + + +@then("every entity data should be accessible as a list or object") +def assert_entity_data_accessible(ctx: dict): + config: ConfigData = ctx["config"] + for co in config.config_objects: + for ed in co.entities: + raw = ed.data + try: + result = raw.as_list() + assert result is not None + except ValueError: + result = raw.as_object() + assert result is not None diff --git a/tests/cbc/unit/__init__.py b/tests/cbc/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py new file mode 100644 index 00000000..9b036719 --- /dev/null +++ b/tests/cbc/unit/test_client.py @@ -0,0 +1,557 @@ +"""Unit tests for DefaultClient and create_client.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, cast +from unittest.mock import MagicMock + +import httpx +import json +import pytest +import ssl + +from sap_cloud_sdk.cbc.client import DefaultClient, create_client, _is_tls_failure +from sap_cloud_sdk.cbc.exceptions import ( + CBCClientError, + CBCConfigError, + CBCNetworkError, + CBCServerError, +) +from sap_cloud_sdk.cbc._models import ConfigData + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _app_tid(value: str = "app-tenant") -> Callable[[], str]: + return lambda: value + + +def _mock_response( + status_code: int = 200, + json_body: Any = None, + content: bytes | None = None, +) -> httpx.Response: + if content is not None: + return httpx.Response(status_code=status_code, content=content) + body = json.dumps(json_body or {}).encode() + return httpx.Response( + status_code=status_code, + content=body, + headers={"content-type": "application/json"}, + ) + + +def _make_client( + base_url: str = "https://cbc.example.ondemand.com", + app_tenant_id: Callable[[], str] | None = None, +) -> tuple[DefaultClient, MagicMock]: + mock_http = MagicMock(spec=httpx.Client) + client = DefaultClient( + base_url=lambda: base_url, + app_tenant_id=app_tenant_id or _app_tid(), + http_client=mock_http, + ) + return client, mock_http + + +# --------------------------------------------------------------------------- +# DefaultClient — app_tenant_id callable +# --------------------------------------------------------------------------- + + +class TestAppTenantIdCallable: + def test_callable_is_invoked_on_each_call(self): + call_count = 0 + + def app_tenant_id_fn() -> str: + nonlocal call_count + call_count += 1 + return "app-tenant" + + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.return_value = _mock_response( + json_body={"items": [{"version": "cv1"}]} + ) + client = DefaultClient( + base_url=lambda: "https://cbc.example.ondemand.com", + app_tenant_id=app_tenant_id_fn, + http_client=mock_http, + ) + client.get_consumption_versions() + client.get_consumption_versions() + assert call_count == 2 + + +# --------------------------------------------------------------------------- +# DefaultClient — URL building +# --------------------------------------------------------------------------- + + +class TestConfigurationsUrl: + def test_joins_base_url_and_path_verbatim(self): + client, _ = _make_client("https://my-tenant.cbc.example.ondemand.com") + url = client._configurations_url( + "https://my-tenant.cbc.example.ondemand.com", "/consumptionVersions" + ) + assert url == ( + "https://my-tenant.cbc.example.ondemand.com" + "/configuration/v1/consumptionVersions" + ) + + +# --------------------------------------------------------------------------- +# DefaultClient — get_consumption_versions +# --------------------------------------------------------------------------- + + +class TestGetConsumptionVersions: + def test_returns_parsed_versions(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={"items": [{"version": "cv1"}]} + ) + result = client.get_consumption_versions() + assert len(result.items) == 1 + assert result.items[0].version == "cv1" + + def test_raises_client_error_on_404(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + status_code=404, + content=b'{"error":{"code":"NOT_FOUND","message":"not found"}}', + ) + with pytest.raises(CBCClientError): + client.get_consumption_versions() + + def test_raises_server_error_on_500(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response(status_code=500, content=b"") + with pytest.raises(CBCServerError): + client.get_consumption_versions() + + def test_raises_network_error_on_connection_failure(self): + client, mock_http = _make_client() + mock_http.request.side_effect = httpx.ConnectError("refused") + with pytest.raises(CBCNetworkError): + client.get_consumption_versions() + + +# --------------------------------------------------------------------------- +# DefaultClient — _get_configuration_objects +# --------------------------------------------------------------------------- + + +class TestGetConfigurationObjects: + def test_returns_grouped_config_objects(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={ + "items": [ + { + "configurationObjectId": "payment-config", + "entities": [{"entityId": "payment-mode"}], + }, + { + "configurationObjectId": "tax-config", + "entities": [ + {"entityId": "tax-category"}, + {"entityId": "tax-rate"}, + ], + }, + ] + } + ) + result = client._get_configuration_objects( + "https://cbc.example.ondemand.com", "app-tenant", "cv1" + ) + assert len(result.items) == 2 + assert result.items[0].config_object_id == "payment-config" + assert [e.entity_id for e in result.items[1].entities] == [ + "tax-category", + "tax-rate", + ] + + +# --------------------------------------------------------------------------- +# DefaultClient — _fetch_entity_data +# --------------------------------------------------------------------------- + + +class TestFetchEntityData: + def test_reads_array_content_items(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={ + "metadata": { + "entityName": "tax-category", + "configurationObjectId": "tax-config", + }, + "contentShape": "ARRAY", + "content": {"items": [{"code": "STD"}], "adaptedKeys": []}, + } + ) + result = client._fetch_entity_data( + "https://cbc.example.ondemand.com", + "app-tenant", + "cv1", + "tax-config", + "tax-category", + ) + assert result.entity_id == "tax-category" + assert result.data.as_list() == [{"code": "STD"}] + + def test_reads_object_content_item(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={ + "metadata": { + "entityName": "globalSettings", + "configurationObjectId": "pmc-settings", + }, + "contentShape": "OBJECT", + "content": {"item": {"maxRetries": 3, "timeoutSeconds": 30}}, + } + ) + result = client._fetch_entity_data( + "https://cbc.example.ondemand.com", + "app-tenant", + "cv1", + "pmc-settings", + "globalSettings", + ) + assert result.entity_id == "globalSettings" + assert result.data.as_object() == {"maxRetries": 3, "timeoutSeconds": 30} + + def test_defaults_to_empty_list_when_content_absent(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={"metadata": {"entityName": "policy"}, "contentShape": "ARRAY"} + ) + result = client._fetch_entity_data( + "https://cbc.example.ondemand.com", + "app-tenant", + "cv1", + "policy-config", + "policy", + ) + assert result.data.as_list() == [] + + def test_falls_back_to_path_entity_id_without_metadata(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={"contentShape": "ARRAY", "content": {"items": []}} + ) + result = client._fetch_entity_data( + "https://cbc.example.ondemand.com", + "app-tenant", + "cv1", + "tax-config", + "tax-rate", + ) + assert result.entity_id == "tax-rate" + + +# --------------------------------------------------------------------------- +# DefaultClient — get_configuration +# --------------------------------------------------------------------------- + + +class TestGetConfiguration: + def test_resolves_latest_version_when_none_given(self): + client, mock_http = _make_client() + versions_response = _mock_response(json_body={"items": [{"version": "v2"}]}) + config_objects_response = _mock_response(json_body={"items": []}) + mock_http.request.side_effect = [versions_response, config_objects_response] + + result = client.get_configuration() + assert isinstance(result, ConfigData) + assert result.consumption_version == "v2" + assert result.config_objects == [] + + def test_raises_runtime_error_when_no_versions_exist(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response(json_body={"items": []}) + with pytest.raises(CBCClientError, match="no consumption version"): + client.get_configuration() + + def test_uses_explicit_consumption_version(self): + client, mock_http = _make_client() + config_objects_response = _mock_response( + json_body={ + "items": [ + { + "configurationObjectId": "payment-config", + "entities": [{"entityId": "payment-mode"}], + } + ] + } + ) + data_response = _mock_response( + json_body={ + "metadata": {"entityName": "payment-mode"}, + "contentShape": "ARRAY", + "content": {"items": [{"k": "v"}]}, + } + ) + mock_http.request.side_effect = [config_objects_response, data_response] + + result = client.get_configuration(consumption_version="cv1") + assert len(result.config_objects) == 1 + assert result.config_objects[0].config_object_id == "payment-config" + assert len(result.config_objects[0].entities) == 1 + assert result.config_objects[0].entities[0].entity_id == "payment-mode" + + def test_resolves_base_url_once_per_call(self): + """One get_configuration resolves base_url once, not per HTTP request.""" + call_count = 0 + + def base_url_fn() -> str: + nonlocal call_count + call_count += 1 + return "https://cbc.example.ondemand.com" + + mock_http = MagicMock(spec=httpx.Client) + client = DefaultClient( + base_url=base_url_fn, + app_tenant_id=_app_tid(), + http_client=mock_http, + ) + config_objects_response = _mock_response( + json_body={ + "items": [ + { + "configurationObjectId": "tax-config", + "entities": [ + {"entityId": "tax-category"}, + {"entityId": "tax-rate"}, + ], + } + ] + } + ) + data_response = _mock_response( + json_body={"contentShape": "ARRAY", "content": {"items": []}} + ) + # config-objects + 2 entity-data requests = 3 HTTP calls, 1 base_url resolve + mock_http.request.side_effect = [ + config_objects_response, + data_response, + data_response, + ] + + client.get_configuration(consumption_version="cv1") + assert call_count == 1 + + +# --------------------------------------------------------------------------- +# DefaultClient — context manager +# --------------------------------------------------------------------------- + + +class TestDefaultClientContextManager: + def test_close_called_on_exit(self): + client, mock_http = _make_client() + with client: + pass + mock_http.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# create_client +# --------------------------------------------------------------------------- + + +class TestCreateClient: + def test_returns_default_client(self): + client = create_client( + base_url=lambda: "http://localhost:8001", + app_tenant_id=lambda: "app-t1", + ) + assert isinstance(client, DefaultClient) + + def test_passes_ssl_context_through(self): + import ssl + + ctx = ssl.create_default_context() + client = create_client( + base_url=lambda: "https://cbc.example.ondemand.com", + app_tenant_id=lambda: "app-t1", + ssl_context=lambda: ctx, + ) + assert isinstance(client, DefaultClient) + + +# --------------------------------------------------------------------------- +# _is_tls_failure +# --------------------------------------------------------------------------- + + +def _tls_read_error() -> httpx.ReadError: + """A ReadError wrapping an ssl.SSLError, as httpx surfaces an expired cert. + + Mirrors the empirically-observed shape: httpx.ReadError whose cause chain + reaches an ssl.SSLError (SSLV3_ALERT_CERTIFICATE_EXPIRED) a couple of levels + down. + """ + ssl_err = ssl.SSLError("[SSL: SSLV3_ALERT_CERTIFICATE_EXPIRED] certificate expired") + read_err = httpx.ReadError("TLS handshake failed") + read_err.__cause__ = ssl_err + return read_err + + +class TestIsTlsFailure: + def test_true_for_read_error_wrapping_ssl_error(self): + assert _is_tls_failure(_tls_read_error()) is True + + def test_false_for_plain_connect_error(self): + assert _is_tls_failure(httpx.ConnectError("connection refused")) is False + + def test_false_for_non_ssl_os_error(self): + err = httpx.ReadError("read failed") + err.__cause__ = OSError("broken pipe") + assert _is_tls_failure(err) is False + + +# --------------------------------------------------------------------------- +# DefaultClient — mTLS certificate rotation (reactive rebuild) +# --------------------------------------------------------------------------- + + +class TestCertRotation: + def _rotating_client( + self, factory: Callable[[], ssl.SSLContext] + ) -> tuple[DefaultClient, list[MagicMock]]: + """A DefaultClient whose _build_http_client hands out fresh mock http clients. + + Each rebuild appends a new mock to the returned list, so a test can + assert how many times (and with what behaviour) the client was rebuilt. + The provided ``factory`` is wired as ``ssl_context`` so its invocation + count reflects each reactive rebuild (the initial mock below is injected + directly, bypassing the factory, as the real ``http_client`` seam does). + """ + built: list[MagicMock] = [] + + def build() -> httpx.Client: + factory() # exercise the ssl factory, same as the real _build_http_client + mock = MagicMock(spec=httpx.Client) + built.append(mock) + return mock + + initial = MagicMock(spec=httpx.Client) + built.append(initial) + client = DefaultClient( + base_url=lambda: "https://cbc.example.ondemand.com", + app_tenant_id=_app_tid(), + http_client=initial, + ssl_context=factory, + ) + client._build_http_client = build # ty: ignore[invalid-assignment] + return client, built + + def test_reactive_success_rebuilds_and_retries(self): + calls = {"factory": 0} + + def factory() -> ssl.SSLContext: + calls["factory"] += 1 + return ssl.create_default_context() + + client, built = self._rotating_client(factory) + ok = _mock_response(json_body={"items": [{"version": "cv1"}]}) + built[0].request.side_effect = _tls_read_error() + # the rebuilt client answers ok; wire it the moment it is created + orig_build = client._build_http_client + + def build_then_prime() -> httpx.Client: + mock = cast(MagicMock, orig_build()) + mock.request.return_value = ok + return mock + + client._build_http_client = build_then_prime # ty: ignore[invalid-assignment] + + result = client.get_consumption_versions() + + assert len(built) == 2 # initial + one rebuild + assert calls["factory"] == 1 # factory invoked once, on the rebuild + built[1].request.assert_called_once() + assert result.items[0].version == "cv1" + + def test_second_failure_propagates_after_one_rebuild(self): + def factory() -> ssl.SSLContext: + return ssl.create_default_context() + + client, built = self._rotating_client(factory) + built[0].request.side_effect = _tls_read_error() + orig_build = client._build_http_client + + def build_then_fail() -> httpx.Client: + mock = cast(MagicMock, orig_build()) + mock.request.side_effect = _tls_read_error() + return mock + + client._build_http_client = build_then_fail # ty: ignore[invalid-assignment] + + with pytest.raises(CBCNetworkError): + client.get_consumption_versions() + + # exactly one rebuild → built[0] + built[1]; no third client + assert len(built) == 2 + built[1].request.assert_called_once() + + def test_non_tls_transport_error_does_not_rebuild(self): + def factory() -> ssl.SSLContext: + return ssl.create_default_context() + + client, built = self._rotating_client(factory) + built[0].request.side_effect = httpx.ConnectError("connection refused") + + with pytest.raises(CBCNetworkError): + client.get_consumption_versions() + + # no rebuild — still only the initial mock + assert len(built) == 1 + + def test_no_factory_does_not_rebuild(self): + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.side_effect = _tls_read_error() + client = DefaultClient( + base_url=lambda: "https://cbc.example.ondemand.com", + app_tenant_id=_app_tid(), + http_client=mock_http, + ssl_context=None, + ) + with pytest.raises(CBCNetworkError): + client.get_consumption_versions() + # one attempt only, no rebuild path + mock_http.request.assert_called_once() + + def test_rebuild_failure_propagates_and_keeps_client(self): + calls = {"factory": 0} + + def factory() -> ssl.SSLContext: + calls["factory"] += 1 + # The initial client is injected via http_client (factory not called + # at construction), so the first invocation is the reactive rebuild — + # which fails, as a rotated-but-unfetchable cert would. + raise CBCConfigError("could not fetch the mTLS certificate") + + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.side_effect = _tls_read_error() + client = DefaultClient( + base_url=lambda: "https://cbc.example.ondemand.com", + app_tenant_id=_app_tid(), + http_client=mock_http, + ssl_context=factory, + ) + original = client._http_client + + with pytest.raises(CBCConfigError, match="mTLS certificate"): + client.get_consumption_versions() + + # factory invoked once on the failed rebuild; client left on the prior one + assert calls["factory"] == 1 + assert client._http_client is original + mock_http.request.assert_called_once() diff --git a/tests/cbc/unit/test_client_adapter.py b/tests/cbc/unit/test_client_adapter.py new file mode 100644 index 00000000..afe6e02e --- /dev/null +++ b/tests/cbc/unit/test_client_adapter.py @@ -0,0 +1,311 @@ +"""Unit tests for the CBC platform adapter.""" + +from __future__ import annotations + +import ssl +from unittest.mock import MagicMock, patch + +import pytest + +from sap_cloud_sdk.cbc.client_adapter import ( + CBC_FRAGMENT_PREFIX, + ENV_CERT_NAME, + ENV_DESTINATION_INSTANCE, + ENV_LANDSCAPE, + app_tenant_id_var, + create_agent_client, + load_ssl_context, + resolve_app_tenant_id, + resolve_base_url, + tenant_subdomain_var, +) +from sap_cloud_sdk.cbc.client import DefaultClient, create_client +from sap_cloud_sdk.cbc.config import CBCDestinationConfig +from sap_cloud_sdk.cbc.exceptions import CBCConfigError + + +@pytest.fixture(autouse=True) +def _reset_contextvars(): + app_tenant_id_var.set("") + tenant_subdomain_var.set("") + yield + + +# --------------------------------------------------------------------------- +# resolve_app_tenant_id +# --------------------------------------------------------------------------- + + +class TestResolveAppTenantId: + def test_returns_contextvar_value(self): + app_tenant_id_var.set("app-t1") + assert resolve_app_tenant_id() == "app-t1" + + def test_raises_when_empty(self): + with pytest.raises(CBCConfigError, match="cbc_app_tenant_id"): + resolve_app_tenant_id() + + +# --------------------------------------------------------------------------- +# resolve_base_url +# --------------------------------------------------------------------------- + + +class TestResolveBaseUrl: + def test_reads_cbc_url_from_matching_fragment(self): + tenant_subdomain_var.set("appfnd-subscriber") + app_tenant_id_var.set("app-t1") + other = MagicMock() + other.name = f"{CBC_FRAGMENT_PREFIX}cbc-other" + other.properties = {"appTenantId": "app-other", "cbcUrl": "https://nope"} + match = MagicMock() + match.name = f"{CBC_FRAGMENT_PREFIX}cbc-t1" + match.properties = { + "appTenantId": "app-t1", + "cbcUrl": "https://cbc.example.cloud.sap", + } + fake_client = MagicMock() + fake_client.list_subaccount_fragments.return_value = [other, match] + + with patch( + "sap_cloud_sdk.destination.create_fragment_client", + return_value=fake_client, + ): + url = resolve_base_url("default") + + assert url == "https://cbc.example.cloud.sap" + fake_client.list_subaccount_fragments.assert_called_once_with( + tenant="appfnd-subscriber" + ) + + def test_raises_when_subdomain_empty(self): + app_tenant_id_var.set("app-t1") + with pytest.raises(CBCConfigError, match="cbc_tenant_subdomain"): + resolve_base_url("default") + + def test_raises_when_app_tenant_id_empty(self): + tenant_subdomain_var.set("appfnd-subscriber") + with pytest.raises(CBCConfigError, match="cbc_app_tenant_id"): + resolve_base_url("default") + + def test_raises_when_no_matching_fragment(self): + tenant_subdomain_var.set("appfnd-subscriber") + app_tenant_id_var.set("app-t1") + other = MagicMock() + other.name = f"{CBC_FRAGMENT_PREFIX}cbc-other" + other.properties = {"appTenantId": "app-other", "cbcUrl": "https://nope"} + fake_client = MagicMock() + fake_client.list_subaccount_fragments.return_value = [other] + with patch( + "sap_cloud_sdk.destination.create_fragment_client", + return_value=fake_client, + ): + with pytest.raises(CBCConfigError, match="No CBC mapping fragment"): + resolve_base_url("default") + + def test_raises_when_cbc_url_missing_from_fragment(self): + tenant_subdomain_var.set("appfnd-subscriber") + app_tenant_id_var.set("app-t1") + match = MagicMock() + match.name = f"{CBC_FRAGMENT_PREFIX}cbc-t1" + match.properties = {"appTenantId": "app-t1"} + fake_client = MagicMock() + fake_client.list_subaccount_fragments.return_value = [match] + with patch( + "sap_cloud_sdk.destination.create_fragment_client", + return_value=fake_client, + ): + with pytest.raises(CBCConfigError, match="no 'cbcUrl'"): + resolve_base_url("default") + + def test_wraps_destination_error_as_config_error(self): + from sap_cloud_sdk.destination.exceptions import DestinationOperationError + + tenant_subdomain_var.set("appfnd-subscriber") + app_tenant_id_var.set("app-t1") + fake_client = MagicMock() + fake_client.list_subaccount_fragments.side_effect = DestinationOperationError( + "failed to list subaccount fragments: token error" + ) + with patch( + "sap_cloud_sdk.destination.create_fragment_client", + return_value=fake_client, + ): + with pytest.raises(CBCConfigError, match="Could not resolve the CBC URL"): + resolve_base_url("default") + + +# --------------------------------------------------------------------------- +# load_ssl_context +# --------------------------------------------------------------------------- + + +class TestLoadSslContext: + def test_loads_cert_into_ssl_context(self): + ctx = ssl.create_default_context() + cert = MagicMock() + cert.content = "" + cert.name = "my-cert.pem" + fake_cert_client = MagicMock() + fake_cert_client.get_subaccount_certificate.return_value = cert + + with ( + patch( + "sap_cloud_sdk.destination.create_certificate_client", + return_value=fake_cert_client, + ), + patch( + "sap_cloud_sdk.destination._cert_loader._load_pem", + return_value=ctx, + ) as load_pem, + ): + result = load_ssl_context("default", "my-cert.pem", b"secret") + + assert result is ctx + load_pem.assert_called_once_with("", b"secret", "my-cert.pem") + + def test_raises_when_cert_not_found(self): + fake_cert_client = MagicMock() + fake_cert_client.get_subaccount_certificate.return_value = None + with patch( + "sap_cloud_sdk.destination.create_certificate_client", + return_value=fake_cert_client, + ): + with pytest.raises(CBCConfigError, match="not found"): + load_ssl_context("default", "missing.pem", None) + + def test_wraps_destination_error_as_config_error(self): + from sap_cloud_sdk.destination.exceptions import DestinationOperationError + + fake_cert_client = MagicMock() + fake_cert_client.get_subaccount_certificate.side_effect = ( + DestinationOperationError("token error") + ) + with patch( + "sap_cloud_sdk.destination.create_certificate_client", + return_value=fake_cert_client, + ): + with pytest.raises( + CBCConfigError, match="Could not fetch the mTLS certificate" + ): + load_ssl_context("default", "my-cert.pem", None) + + +# --------------------------------------------------------------------------- +# create_agent_client +# --------------------------------------------------------------------------- + + +class TestCreateAgentClient: + def test_builds_ssl_context_from_cert_default(self, monkeypatch): + monkeypatch.setenv(ENV_LANDSCAPE, "cbc-fndtst-dev-eu12") + with patch( + "sap_cloud_sdk.cbc.client_adapter.load_ssl_context", + return_value=ssl.create_default_context(), + ) as load: + client = create_agent_client() + assert isinstance(client, DefaultClient) + # The adapter wires a factory that resolves the cert; the core + # invokes it once at construction to build the mTLS client. + load.assert_called_once() + instance_arg, cert_arg, _pw = load.call_args.args + assert instance_arg == "default" + assert cert_arg == "sap-managed-runtime-ias-cbc-fndtst-dev-eu12.pem" + # The factory is re-invokable (used again on a TLS failure to reload). + assert client._ssl_factory is not None + client._ssl_factory() + assert load.call_count == 2 + + def test_env_overrides_apply(self, monkeypatch): + monkeypatch.setenv(ENV_DESTINATION_INSTANCE, "cbc-instance") + monkeypatch.setenv(ENV_CERT_NAME, "my-cert.pem") + with patch( + "sap_cloud_sdk.cbc.client_adapter.load_ssl_context", + return_value=ssl.create_default_context(), + ) as load: + create_agent_client() + instance_arg, cert_arg, _pw = load.call_args.args + assert instance_arg == "cbc-instance" + assert cert_arg == "my-cert.pem" + + def test_config_values_apply(self): + with patch( + "sap_cloud_sdk.cbc.client_adapter.load_ssl_context", + return_value=ssl.create_default_context(), + ) as load: + create_agent_client( + config=CBCDestinationConfig( + destination_instance="cbc-instance", + cbc_cert_name="my-cert.pem", + ) + ) + instance_arg, cert_arg, _pw = load.call_args.args + assert instance_arg == "cbc-instance" + assert cert_arg == "my-cert.pem" + + def test_config_value_wins_over_env(self, monkeypatch): + monkeypatch.setenv(ENV_DESTINATION_INSTANCE, "from-env") + monkeypatch.setenv(ENV_CERT_NAME, "from-env.pem") + with patch( + "sap_cloud_sdk.cbc.client_adapter.load_ssl_context", + return_value=ssl.create_default_context(), + ) as load: + create_agent_client( + config=CBCDestinationConfig( + destination_instance="from-config", + cbc_cert_name="from-config.pem", + ) + ) + instance_arg, cert_arg, _pw = load.call_args.args + assert instance_arg == "from-config" + assert cert_arg == "from-config.pem" + + def test_raises_when_landscape_unset_and_no_cert_name(self, monkeypatch): + monkeypatch.delenv(ENV_LANDSCAPE, raising=False) + monkeypatch.delenv(ENV_CERT_NAME, raising=False) + with pytest.raises(CBCConfigError, match=ENV_LANDSCAPE): + create_agent_client() + + def test_wires_resolvers_into_client(self, monkeypatch): + """The two ContextVars drive base_url + app_tenant_id at request time.""" + monkeypatch.setenv(ENV_CERT_NAME, "my-cert.pem") + with patch( + "sap_cloud_sdk.cbc.client_adapter.load_ssl_context", + return_value=ssl.create_default_context(), + ): + client = create_agent_client() + assert isinstance(client, DefaultClient) + + app_tenant_id_var.set("app-t1") + assert client._resolve_app_tenant_id() == "app-t1" + + tenant_subdomain_var.set("appfnd-subscriber") + fragment = MagicMock() + fragment.name = f"{CBC_FRAGMENT_PREFIX}cbc-t1" + fragment.properties = { + "appTenantId": "app-t1", + "cbcUrl": "https://cbc.example.cloud.sap", + } + fake_fc = MagicMock() + fake_fc.list_subaccount_fragments.return_value = [fragment] + with patch( + "sap_cloud_sdk.destination.create_fragment_client", return_value=fake_fc + ): + assert client._base_url() == "https://cbc.example.cloud.sap" + + +# --------------------------------------------------------------------------- +# Compose path — public resolvers + create_client (override one axis) +# --------------------------------------------------------------------------- + + +class TestComposeWithPublicResolvers: + def test_compose_core_with_public_resolvers(self): + """Advanced callers keep platform base_url + app_tenant_id but bring + their own mTLS context, composing the public resolvers with the core.""" + client = create_client( + base_url=lambda: resolve_base_url("default"), + app_tenant_id=resolve_app_tenant_id, + ssl_context=lambda: ssl.create_default_context(), + ) + assert isinstance(client, DefaultClient) diff --git a/tests/cbc/unit/test_models.py b/tests/cbc/unit/test_models.py new file mode 100644 index 00000000..eb37624d --- /dev/null +++ b/tests/cbc/unit/test_models.py @@ -0,0 +1,180 @@ +"""Unit tests for CBC data models.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from sap_cloud_sdk.cbc._models import ( + ApiError, + ConfigData, + ConfigObject, + ConsumptionVersion, + ConsumptionVersions, + EntityContent, + EntityData, +) + + +# --------------------------------------------------------------------------- +# ConsumptionVersions.latest() +# --------------------------------------------------------------------------- + + +class TestConsumptionVersionsLatest: + def _version( + self, + version: str, + modified: datetime | None = None, + created: datetime | None = None, + ) -> ConsumptionVersion: + return ConsumptionVersion( + version=version, + modifiedDate=modified, + createdDate=created, + ) + + def test_returns_none_for_empty_list(self): + assert ConsumptionVersions(items=[]).latest() is None + + def test_returns_latest_by_modified_date(self): + t1 = datetime(2024, 1, 1, tzinfo=timezone.utc) + t2 = datetime(2024, 6, 1, tzinfo=timezone.utc) + v = ConsumptionVersions( + items=[ + self._version("v1", modified=t1), + self._version("v2", modified=t2), + ] + ) + result = v.latest() + assert result is not None + assert result.version == "v2" + + def test_returns_latest_by_created_date_when_no_modified(self): + t1 = datetime(2024, 1, 1, tzinfo=timezone.utc) + t2 = datetime(2024, 6, 1, tzinfo=timezone.utc) + v = ConsumptionVersions( + items=[ + self._version("v1", created=t1), + self._version("v2", created=t2), + ] + ) + result = v.latest() + assert result is not None + assert result.version == "v2" + + def test_returns_last_item_when_no_dates(self): + v = ConsumptionVersions(items=[self._version("v1"), self._version("v2")]) + result = v.latest() + assert result is not None + assert result.version == "v2" + + +# --------------------------------------------------------------------------- +# EntityContent +# --------------------------------------------------------------------------- + + +class TestEntityContent: + def test_as_list_returns_list(self): + ec = EntityContent([{"k": "v"}]) + assert ec.as_list() == [{"k": "v"}] + + def test_as_list_raises_when_dict(self): + ec = EntityContent({"k": "v"}) + with pytest.raises(ValueError, match="as_object"): + ec.as_list() + + def test_as_object_returns_dict(self): + ec = EntityContent({"k": "v"}) + assert ec.as_object() == {"k": "v"} + + def test_as_object_raises_when_list(self): + ec = EntityContent([{"k": "v"}]) + with pytest.raises(ValueError, match="as_list"): + ec.as_object() + + def test_is_list_and_is_object_for_list_content(self): + ec = EntityContent([{"k": "v"}]) + assert ec.is_list() is True + assert ec.is_object() is False + + def test_is_list_and_is_object_for_dict_content(self): + ec = EntityContent({"k": "v"}) + assert ec.is_object() is True + assert ec.is_list() is False + + def test_value_returns_raw_without_asserting_shape(self): + assert EntityContent([{"k": "v"}]).value() == [{"k": "v"}] + assert EntityContent({"k": "v"}).value() == {"k": "v"} + + +# --------------------------------------------------------------------------- +# ConfigData helpers +# --------------------------------------------------------------------------- + + +class TestConfigData: + def _entity_data(self, entity_id: str) -> EntityData: + return EntityData(entity_id=entity_id, data=EntityContent([])) + + def _config_object(self, config_object_id: str, *entity_ids: str) -> ConfigObject: + return ConfigObject( + config_object_id=config_object_id, + entities=[self._entity_data(eid) for eid in entity_ids], + ) + + def _config(self, *config_objects: ConfigObject) -> ConfigData: + return ConfigData( + consumption_version="cv1", + app_tenant_id="app-t1", + config_objects=list(config_objects), + ) + + def test_get_config_object_returns_matching(self): + config = self._config( + self._config_object("ObjA", "E1"), + self._config_object("ObjB", "E2"), + ) + result = config.get_config_object("ObjA") + assert result is not None + assert result.config_object_id == "ObjA" + + def test_get_config_object_returns_none_when_missing(self): + config = self._config(self._config_object("ObjA", "E1")) + assert config.get_config_object("Missing") is None + + def test_get_entity_data_returns_match(self): + config = self._config( + self._config_object("ObjA", "E1", "E2"), + ) + result = config.get_entity_data("ObjA", "E2") + assert result is not None + assert result.entity_id == "E2" + + def test_get_entity_data_returns_none_when_missing(self): + config = self._config(self._config_object("ObjA", "E1")) + assert config.get_entity_data("ObjA", "Missing") is None + + +# --------------------------------------------------------------------------- +# ApiError.from_response +# --------------------------------------------------------------------------- + + +class TestApiError: + def test_parses_cbc_error_envelope(self): + body = b'{"error":{"code":"NOT_FOUND","message":"Resource not found"}}' + err = ApiError.from_response(body) + assert err.code == "NOT_FOUND" + assert err.message == "Resource not found" + + def test_fallback_on_empty_body(self): + err = ApiError.from_response(None) + assert err.code == "UNKNOWN_ERROR" + + def test_fallback_on_unparseable_body(self): + err = ApiError.from_response(b"not json") + assert err.code == "UNKNOWN_ERROR" + assert "not json" in err.message diff --git a/tests/core/unit/telemetry/test_module.py b/tests/core/unit/telemetry/test_module.py index 6d38697a..77cb965a 100644 --- a/tests/core/unit/telemetry/test_module.py +++ b/tests/core/unit/telemetry/test_module.py @@ -58,8 +58,9 @@ def test_module_in_collection(self): def test_all_modules_present(self): """Test that all expected modules are present.""" all_modules = list(Module) - assert len(all_modules) == 16 + assert len(all_modules) == 17 assert Module.ADMS in all_modules + assert Module.CBC in all_modules assert Module.AGENT_MEMORY in all_modules assert Module.AGENTGATEWAY in all_modules assert Module.AICORE in all_modules diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index a4ba6ee3..b1456b9d 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -317,5 +317,6 @@ def test_operation_count(self): all_operations = list(Operation) # 3 auditlog + 12 destination + 10 certificate + 10 fragment + 8 objectstore # + 2 extensibility + 7 aicore + 23 dms + 6 agentgateway + 13 agent_memory - # + 5 data_anonymization + 52 adms + 6 print + 94 dpi_ng + 1 bootstrap + 3 output_management = 255 - assert len(all_operations) == 255 + # + 5 data_anonymization + 52 adms + 6 print + 94 dpi_ng + 1 bootstrap + 3 output_management + # + 2 cbc = 257 + assert len(all_operations) == 257 diff --git a/uv.lock b/uv.lock index 78dce635..f392fca5 100644 --- a/uv.lock +++ b/uv.lock @@ -4363,7 +4363,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.57.2" +version = "0.58.0" source = { editable = "." } dependencies = [ { name = "cryptography" },