From 63b0349b2cc4ee440ad0a5bf91695380807ab364 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sun, 13 Sep 2026 23:12:17 +0530 Subject: [PATCH 01/14] feat(cbc): add CBC (Central Business Configuration) client module Typed Python client for reading tenant-specific business configuration from SAP Central Business Configuration. Supports mTLS (production), local/mock (loopback auto-detection), and HTTPS mock servers via the CLOUD_SDK_CBC_REPLACE_SUBDOMAIN env var override. Public API: create_client(), CBCClient protocol, DefaultClient, CBCConfig, ConfigData / ConfigObject / EntityData / EntityContent, ConsumptionVersions, and a full CBC exception hierarchy. --- src/sap_cloud_sdk/cbc/__init__.py | 94 ++++ src/sap_cloud_sdk/cbc/_http.py | 87 ++++ src/sap_cloud_sdk/cbc/_models.py | 297 ++++++++++++ src/sap_cloud_sdk/cbc/client.py | 456 ++++++++++++++++++ src/sap_cloud_sdk/cbc/config.py | 113 +++++ src/sap_cloud_sdk/cbc/exceptions.py | 89 ++++ src/sap_cloud_sdk/cbc/py.typed | 0 src/sap_cloud_sdk/cbc/user-guide.md | 163 +++++++ src/sap_cloud_sdk/core/telemetry/module.py | 1 + src/sap_cloud_sdk/core/telemetry/operation.py | 4 + tests/cbc/__init__.py | 0 tests/cbc/integration/__init__.py | 0 tests/cbc/integration/cbc.feature | 30 ++ tests/cbc/integration/conftest.py | 44 ++ tests/cbc/integration/test_e2e_bdd.py | 141 ++++++ tests/cbc/unit/__init__.py | 0 tests/cbc/unit/test_client.py | 295 +++++++++++ tests/cbc/unit/test_config.py | 86 ++++ tests/cbc/unit/test_models.py | 183 +++++++ 19 files changed, 2083 insertions(+) create mode 100644 src/sap_cloud_sdk/cbc/__init__.py create mode 100644 src/sap_cloud_sdk/cbc/_http.py create mode 100644 src/sap_cloud_sdk/cbc/_models.py create mode 100644 src/sap_cloud_sdk/cbc/client.py create mode 100644 src/sap_cloud_sdk/cbc/config.py create mode 100644 src/sap_cloud_sdk/cbc/exceptions.py create mode 100644 src/sap_cloud_sdk/cbc/py.typed create mode 100644 src/sap_cloud_sdk/cbc/user-guide.md create mode 100644 tests/cbc/__init__.py create mode 100644 tests/cbc/integration/__init__.py create mode 100644 tests/cbc/integration/cbc.feature create mode 100644 tests/cbc/integration/conftest.py create mode 100644 tests/cbc/integration/test_e2e_bdd.py create mode 100644 tests/cbc/unit/__init__.py create mode 100644 tests/cbc/unit/test_client.py create mode 100644 tests/cbc/unit/test_config.py create mode 100644 tests/cbc/unit/test_models.py diff --git a/src/sap_cloud_sdk/cbc/__init__.py b/src/sap_cloud_sdk/cbc/__init__.py new file mode 100644 index 00000000..e03cbfd0 --- /dev/null +++ b/src/sap_cloud_sdk/cbc/__init__.py @@ -0,0 +1,94 @@ +"""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.cbc import create_client, TenantContext + + client = create_client() + config = client.get_configuration( + TenantContext(cbcTenantId="my-cbc-tenant", appTenantId="my-app-tenant") + ) + + # 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) + +Local / mock server — no credentials needed:: + + from sap_cloud_sdk.cbc import DefaultClient, TenantContext + + client = DefaultClient(base_url="http://localhost:8001") + config = client.get_configuration( + TenantContext(cbcTenantId="t1", appTenantId="app-t1") + ) +""" + +from __future__ import annotations + +from sap_cloud_sdk.cbc.client import ( + CBCClient, + DefaultClient, + create_client, +) +from sap_cloud_sdk.cbc.config import CBCConfig +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, + TenantContext, +) + + +__all__ = [ + # factories + "create_client", + # clients + "CBCClient", + "DefaultClient", + # config + "CBCConfig", + # exceptions + "CBCError", + "CBCClientError", + "CBCConfigError", + "CBCHttpError", + "CBCNetworkError", + "CBCServerError", + "HttpContext", + # models — context + "TenantContext", + # 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/_http.py b/src/sap_cloud_sdk/cbc/_http.py new file mode 100644 index 00000000..5c5ecafd --- /dev/null +++ b/src/sap_cloud_sdk/cbc/_http.py @@ -0,0 +1,87 @@ +"""Low-level HTTP transport for the CBC (Central Business Configuration) module. + +Provides: +- :func:`_is_local_url` — detects loopback URLs that skip mTLS and subdomain routing. +- :class:`_LazyCertTransport` — httpx transport that defers mTLS cert loading + until the first real connection, so clients can be constructed with cert data + that has not yet been written to disk. +""" + +from __future__ import annotations + +import contextlib +import os +import ssl + +import httpx + + +def _is_local_url(url: str) -> bool: + """Return ``True`` when *url* targets a loopback address. + + Loopback addresses (``http://localhost``, ``http://127.0.0.1``, + ``http://[::1]``) bypass mTLS and subdomain-per-tenant routing — they + point directly at a mock or local dev server. + + Args: + url: Base URL to test. + + Returns: + ``True`` if the URL targets a loopback address, ``False`` otherwise. + """ + lower = url.lower() + return ( + lower.startswith("http://localhost") + or lower.startswith("http://127.0.0.1") + or lower.startswith("http://[::1]") + ) + + +class _LazyCertTransport(httpx.BaseTransport): + """httpx transport that defers ``ssl.SSLContext.load_cert_chain`` until first use. + + Cert files are not validated at construction time — the chain is loaded once, + lazily, before the first real HTTP connection. This allows :class:`DefaultClient` + to be instantiated with cert paths that are written after construction (e.g. in + tests), and avoids I/O at import time. + + Args: + cert_file: Path to the PEM-encoded client certificate file. + key_file: Path to the PEM-encoded private key file. + delete_after_load: When ``True``, both files are deleted from disk after + the cert chain is loaded. Use for temporary files written from + in-memory PEM strings. + """ + + def __init__( + self, cert_file: str, key_file: str, *, delete_after_load: bool = False + ) -> None: + self._cert_file = cert_file + self._key_file = key_file + self._delete_after_load = delete_after_load + self._inner: httpx.HTTPTransport | None = None + self._files_deleted = False + + def _ensure_inner(self) -> httpx.HTTPTransport: + if self._inner is None: + ctx = ssl.create_default_context() + ctx.load_cert_chain(certfile=self._cert_file, keyfile=self._key_file) + if self._delete_after_load: + os.unlink(self._cert_file) + os.unlink(self._key_file) + self._files_deleted = True + self._inner = httpx.HTTPTransport(verify=ctx) + return self._inner + + def handle_request(self, request: httpx.Request) -> httpx.Response: + return self._ensure_inner().handle_request(request) + + def close(self) -> None: + if self._delete_after_load and not self._files_deleted: + with contextlib.suppress(OSError): + os.unlink(self._cert_file) + with contextlib.suppress(OSError): + os.unlink(self._key_file) + self._files_deleted = True + if self._inner is not None: + self._inner.close() diff --git a/src/sap_cloud_sdk/cbc/_models.py b/src/sap_cloud_sdk/cbc/_models.py new file mode 100644 index 00000000..d68def49 --- /dev/null +++ b/src/sap_cloud_sdk/cbc/_models.py @@ -0,0 +1,297 @@ +"""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) + + +# --------------------------------------------------------------------------- +# Core context models +# --------------------------------------------------------------------------- + + +class TenantContext(_FrozenModel): + """Tenant identification required for all CBC API calls. + + Attributes: + cbc_tenant_id: CBC tenant identifier (subdomain used in URL routing). + app_tenant_id: Application-level tenant identifier. + """ + + cbc_tenant_id: str = Field(alias="cbcTenantId", min_length=1) + app_tenant_id: str = Field(alias="appTenantId", min_length=1) + + +# --------------------------------------------------------------------------- +# 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 Entity(_FrozenModel): + """Metadata describing one entity within a consumption version. + + A config object groups one or several related entities, each holding a + different slice of the configuration. Use ``config_object_id`` and + ``entity_id`` together to locate the entity you need. + + Attributes: + internal_id: CBC-internal opaque identifier (used in API path calls). + entity_id: Authored entity key (e.g. ``"payment-mode"``). + config_object_id: Configuration object this entity belongs to. + """ + + # CBC API: "entityId" is the internal GUID used in URL paths; + # "entityName" is the authored key (e.g. "payment-mode"). + internal_id: str = Field(alias="entityId") + entity_id: str | None = Field(default=None, alias="entityName") + config_object_id: str | None = Field(default=None, alias="configurationObjectId") + + +class Entities(_FrozenModel): + """Collection of business configuration entities. + + Attributes: + items: List of :class:`Entity` objects. + """ + + items: list[Entity] + + + +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 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. + tenant_context: Tenant this data belongs to. + config_objects: Configuration objects and their entity data. + """ + + consumption_version: str + tenant_context: TenantContext + 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..6dc39d24 --- /dev/null +++ b/src/sap_cloud_sdk/cbc/client.py @@ -0,0 +1,456 @@ +"""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. Handles both production (mTLS + + envoy subdomain routing) and local/mock mode (detected automatically from the URL). +- :func:`create_client` — Factory that resolves the right client from environment + variables via :func:`~sap_cloud_sdk.cbc.config.load_from_env`. + +Quick start:: + + from sap_cloud_sdk.cbc import create_client, TenantContext + + client = create_client() + config = client.get_configuration( + TenantContext(cbcTenantId="my-cbc-tenant", appTenantId="my-app-tenant") + ) +""" + +from __future__ import annotations + +import logging +import re +import ssl +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Protocol + +import httpx + +if TYPE_CHECKING: + from sap_cloud_sdk.cbc.config import CBCConfig + +from sap_cloud_sdk.cbc._http import _LazyCertTransport, _is_local_url +from sap_cloud_sdk.cbc._models import ( + ApiError, + ConfigData, + ConfigObject, + ConsumptionVersions, + Entities, + Entity, + EntityContent, + EntityData, + TenantContext, +) +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__) + + +# --------------------------------------------------------------------------- +# 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, tenant_context: TenantContext + ) -> ConsumptionVersions: + """Return the available consumption versions for the given 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, + tenant_context: TenantContext, + 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 _ClientConfig: + """API path and routing configuration for a :class:`DefaultClient` instance.""" + + configurations_path: str + replace_subdomain: bool + + + +# --------------------------------------------------------------------------- +# DefaultClient +# --------------------------------------------------------------------------- + + +class DefaultClient: + """CBC client for both production (mTLS + envoy) and local/mock environments. + + **Production** (any ``https://`` or non-loopback URL): subdomain-per-tenant + routing rewrites the URL subdomain to the ``cbc_tenant_id`` for each request; + mTLS credentials must be provided via ``cert``, ``cert_pem``/``key_pem``, or + ``ssl_context``. + + **Local / mock** (``http://localhost``, ``http://127.0.0.1``, ``http://[::1]``): + no subdomain replacement, no mTLS — detected automatically from the URL. + Point it at the CBC mock server and it works without any extra arguments. + + Do **not** instantiate directly — use :func:`create_client` in production + code, which resolves credentials from the environment automatically. + + Example (local mock):: + + client = DefaultClient(base_url="http://localhost:8001") + config = client.get_configuration( + TenantContext(cbcTenantId="t1", appTenantId="app-t1") + ) + + Example (production):: + + client = DefaultClient( + base_url="https://cbc.example.ondemand.com", + cert=(Path("/run/secrets/tls.crt"), Path("/run/secrets/tls.key")), + ) + + Args: + base_url: Base URL of the CBC service. Loopback addresses trigger + local mode automatically. + http_client: Optional pre-configured ``httpx.Client`` — takes full + precedence over all mTLS arguments. Use for testing. + ssl_context: Optional pre-built :class:`ssl.SSLContext` with mTLS loaded. + cert: ``(cert_path, key_path)`` tuple of :class:`pathlib.Path` objects. + cert_pem: Raw PEM string for the client certificate. Requires + ``key_pem`` to also be set. Written to a temp file deleted after + the first connection. + key_pem: Raw PEM string for the private key. Requires ``cert_pem``. + """ + + def __init__( + self, + base_url: str, + http_client: httpx.Client | None = None, + ssl_context: ssl.SSLContext | None = None, + cert: tuple[Path, Path] | None = None, + cert_pem: str | None = None, + key_pem: str | None = None, + replace_subdomain: bool | None = None, + ) -> None: + self._base_url = base_url.rstrip("/") + resolved_replace = ( + replace_subdomain + if replace_subdomain is not None + else not _is_local_url(base_url) + ) + self._config = _ClientConfig( + configurations_path="/configuration/v1", + replace_subdomain=resolved_replace, + ) + + if http_client is None and ssl_context is None: + if cert is not None: + transport = _LazyCertTransport(str(cert[0]), str(cert[1])) + http_client = httpx.Client(transport=transport) + elif cert_pem is not None and key_pem is not None: + with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as cf: + cf.write(cert_pem.encode()) + cert_file = cf.name + with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as kf: + kf.write(key_pem.encode()) + key_file = kf.name + transport = _LazyCertTransport( + cert_file, key_file, delete_after_load=True + ) + http_client = httpx.Client(transport=transport) + + self._client = http_client or httpx.Client(verify=ssl_context or True) + + def close(self) -> None: + """Close the underlying HTTP client and release connections.""" + self._client.close() + + def __enter__(self) -> "DefaultClient": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + # ------------------------------------------------------------------ + # Public API methods + # ------------------------------------------------------------------ + + @record_metrics(Module.CBC, Operation.CBC_GET_CONSUMPTION_VERSIONS) + def get_consumption_versions( + self, tenant_context: TenantContext + ) -> ConsumptionVersions: + """Return available consumption versions for the given tenant. + + Args: + tenant_context: Tenant identification. + + Returns: + :class:`ConsumptionVersions` with all versions for the tenant. + + Raises: + CBCClientError: On 4xx responses. + CBCServerError: On 5xx responses. + CBCNetworkError: On connection failures. + """ + url = self._configurations_url( + tenant_context, + f"/consumptionVersions?appTenantId={tenant_context.app_tenant_id}", + ) + return ConsumptionVersions.model_validate( + self._request("GET", url).json() + ) + + def _get_entities( + self, tenant_context: TenantContext, consumption_version: str + ) -> Entities: + """Return the entities for the given tenant and consumption version. + + Args: + tenant_context: Tenant identification. + consumption_version: Consumption version ID. + + Returns: + :class:`Entities` containing entity metadata. + + Raises: + CBCClientError: On 4xx responses. + CBCServerError: On 5xx responses. + CBCNetworkError: On connection failures. + """ + url = self._configurations_url( + tenant_context, + f"/consumptionVersions/{consumption_version}/entities" + f"?appTenantId={tenant_context.app_tenant_id}", + ) + return Entities.model_validate(self._request("GET", url).json()) + + def _get_entity_data( + self, + tenant_context: TenantContext, + consumption_version: str, + entity_id: str, + ) -> EntityData: + """Return configuration rows for the given entity. + + Args: + tenant_context: Tenant identification. + consumption_version: Consumption version ID. + entity_id: Entity identifier. + + Returns: + :class:`EntityData` with metadata and configuration rows. + + Raises: + CBCClientError: If the entity is not found, or on other 4xx responses. + CBCServerError: On 5xx responses. + CBCNetworkError: On connection failures. + """ + return self._fetch_entity_data( + tenant_context, consumption_version, Entity(entityId=entity_id) + ) + + @record_metrics(Module.CBC, Operation.CBC_GET_CONFIGURATION) + def get_configuration( + self, + tenant_context: TenantContext, + consumption_version: str | None = None, + ) -> ConfigData: + """Return the full business configuration for the given tenant. + + Fetches all entities and their data for the specified consumption version. + When ``consumption_version`` is omitted, the latest version is resolved + automatically via :meth:`get_consumption_versions`. + + Args: + tenant_context: Tenant identification. + 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. + """ + if consumption_version is None: + versions = self.get_consumption_versions(tenant_context) + latest = versions.latest() + if latest is None: + raise CBCClientError( + f"CBC returned no consumption version for " + f"tenant={tenant_context.app_tenant_id!r}." + ) + consumption_version = latest.version + + entities = self._get_entities(tenant_context, consumption_version) + if not entities.items: + return ConfigData( + consumption_version=consumption_version, + tenant_context=tenant_context, + config_objects=[], + ) + + entity_data_list = [ + self._fetch_entity_data(tenant_context, consumption_version, entity) + for entity in entities.items + ] + + grouped: dict[str, list[EntityData]] = {} + for ed, entity in zip(entity_data_list, entities.items): + key = entity.config_object_id or "" + grouped.setdefault(key, []).append(ed) + + config_objects = [ + ConfigObject(config_object_id=co_id, entities=eds) + for co_id, eds in grouped.items() + ] + return ConfigData( + consumption_version=consumption_version, + tenant_context=tenant_context, + config_objects=config_objects, + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _fetch_entity_data( + self, + tenant_context: TenantContext, + consumption_version: str, + entity: Entity, + ) -> EntityData: + url = self._configurations_url( + tenant_context, + f"/consumptionVersions/{consumption_version}/entities" + f"/{entity.internal_id}/data" + f"?appTenantId={tenant_context.app_tenant_id}", + ) + response_data = self._request("GET", url).json() + + api_meta = response_data.get("metadata", {}) if isinstance(response_data, dict) else {} + raw_data = ( + response_data["items"] + if isinstance(response_data, dict) and "items" in response_data + else response_data + ) + entity_id = entity.entity_id or api_meta.get("entityName") or entity.internal_id + return EntityData(entity_id=entity_id, data=EntityContent(raw_data)) + + def _configurations_url(self, tenant_context: TenantContext, path: str = "") -> str: + base = self._base_url + if self._config.replace_subdomain: + base = re.sub( + r"^(https?://)[^.]+\.", + rf"\g<1>{tenant_context.cbc_tenant_id}.", + base, + ) + 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._client.request( + method=method, + url=url, + headers={}, + json=body, + timeout=30.0, + ) + except httpx.RequestError as exc: + raise CBCNetworkError( + f"Network error calling CBC: {exc}", + http_context=HttpContext( + status_code=-1, request_method=method, request_url=url + ), + ) 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 + + +# --------------------------------------------------------------------------- +# Factory function +# --------------------------------------------------------------------------- + + +def create_client(*, config: CBCConfig | None = None) -> CBCClient: + """Create a :class:`DefaultClient` from environment variables or an explicit config. + + When ``config`` is omitted, credentials are resolved via + :func:`~sap_cloud_sdk.cbc.config.load_from_env` (reads + ``CLOUD_SDK_CBC_URL``, ``CLOUD_SDK_CBC_CERT_PATH``, ``CLOUD_SDK_CBC_KEY_PATH``). + + Args: + config: Optional explicit :class:`~sap_cloud_sdk.cbc.config.CBCConfig`. + When provided, env resolution is skipped entirely. + + Returns: + A configured :class:`DefaultClient`. + + Raises: + CBCConfigError: If no configuration is provided and none can be resolved + from the environment. + """ + from sap_cloud_sdk.cbc.config import load_from_env + + resolved: CBCConfig = config if config is not None else load_from_env() + cert = ( + (resolved.cert_path, resolved.key_path) + if resolved.cert_path and resolved.key_path + else None + ) + return DefaultClient( + base_url=resolved.base_url, + cert=cert, + replace_subdomain=resolved.replace_subdomain, + ) diff --git a/src/sap_cloud_sdk/cbc/config.py b/src/sap_cloud_sdk/cbc/config.py new file mode 100644 index 00000000..2ae1a1cf --- /dev/null +++ b/src/sap_cloud_sdk/cbc/config.py @@ -0,0 +1,113 @@ +"""Configuration and credential resolution for the CBC (Central Business Configuration) module. + +Reads mTLS credentials for the CBC service from environment variables. + +Environment variables:: + + CLOUD_SDK_CBC_URL CBC service base URL (required) + CLOUD_SDK_CBC_CERT_PATH Path to PEM client certificate file + CLOUD_SDK_CBC_KEY_PATH Path to PEM private key file +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from sap_cloud_sdk.cbc.exceptions import CBCConfigError + +ENV_URL = "CLOUD_SDK_CBC_URL" +ENV_CERT_PATH = "CLOUD_SDK_CBC_CERT_PATH" +ENV_KEY_PATH = "CLOUD_SDK_CBC_KEY_PATH" +ENV_REPLACE_SUBDOMAIN = "CLOUD_SDK_CBC_REPLACE_SUBDOMAIN" + + +@dataclass(frozen=True) +class CBCConfig: + """Resolved configuration for the CBC service. + + Attributes: + base_url: CBC service base URL. + cert_path: Path to the PEM client certificate file, or ``None`` for local/mock mode. + key_path: Path to the PEM private key file, or ``None`` for local/mock mode. + replace_subdomain: Whether to rewrite the URL subdomain to the CBC tenant ID + on each request. ``None`` (default) auto-detects: loopback URLs disable it, + all others enable it. Set explicitly to ``False`` for HTTPS mock servers. + """ + + base_url: str + cert_path: Path | None = None + key_path: Path | None = None + replace_subdomain: bool | None = None + + +def load_from_env() -> CBCConfig: + """Load CBC configuration from environment variables. + + Resolution order (first match wins): + + 1. **Credential triplet** — ``CLOUD_SDK_CBC_CERT_PATH``, + ``CLOUD_SDK_CBC_KEY_PATH``, and ``CLOUD_SDK_CBC_URL`` must all be set. + The path vars must point to existing PEM files. + 2. **URL only** — loopback addresses (``http://localhost``, + ``http://127.0.0.1``) trigger local/mock mode (no mTLS, no subdomain + replacement). Non-loopback URLs produce a client without mTLS. + + Returns: + A :class:`CBCConfig` ready for use by :func:`~sap_cloud_sdk.cbc.create_client`. + + Raises: + CBCConfigError: If no configuration is found, or configuration is partially + set and unusable — e.g. only one of the cert/key env vars is set, or a + path env var points to a non-existent file. + """ + url = os.environ.get(ENV_URL) + + cert = _read_env_path(ENV_CERT_PATH) + key = _read_env_path(ENV_KEY_PATH) + if cert and key and url: + return CBCConfig( + base_url=url, + cert_path=cert, + key_path=key, + replace_subdomain=_read_env_bool(ENV_REPLACE_SUBDOMAIN), + ) + if cert or key: + raise CBCConfigError( + "CBC env-var credential triplet is incomplete. " + f"Set all of {ENV_CERT_PATH}, {ENV_KEY_PATH}, and {ENV_URL} — or none." + ) + + if url: + return CBCConfig(base_url=url, replace_subdomain=_read_env_bool(ENV_REPLACE_SUBDOMAIN)) + + raise CBCConfigError( + f"No CBC configuration found. Set {ENV_URL} at minimum, " + f"or provide mTLS credentials via {ENV_CERT_PATH} / {ENV_KEY_PATH}." + ) + + +def _read_env_bool(name: str) -> bool | None: + """Return True/False from env var ``name``, or ``None`` when unset.""" + raw = os.environ.get(name) + if not raw: + return None + return raw.strip().lower() in ("1", "true", "yes") + + +def _read_env_path(name: str) -> Path | None: + """Return the path named by env var ``name``, or ``None`` when unset. + + Raises: + CBCConfigError: If the env var is set but the path does not exist. + """ + raw = os.environ.get(name) + if not raw or not raw.strip(): + return None + p = Path(raw).expanduser() + if not p.exists(): + raise CBCConfigError( + f"Env var {name}={raw!r} points to a path that does not exist." + ) + return p 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..7895e441 --- /dev/null +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -0,0 +1,163 @@ +# 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 + +```python +from sap_cloud_sdk.cbc import create_client, TenantContext + +client = create_client() # reads CLOUD_SDK_CBC_URL, CLOUD_SDK_CBC_CERT_PATH, CLOUD_SDK_CBC_KEY_PATH +``` + +For local development against a mock server — no credentials needed: + +```python +from sap_cloud_sdk.cbc import DefaultClient + +client = DefaultClient(base_url="http://localhost:8001") +``` + +## Reading configuration + +### Fetch everything in one call + +```python +tenant = TenantContext(cbcTenantId="", appTenantId="") + +# latest version resolved automatically +config = client.get_configuration(tenant) + +# pin a specific version +config = client.get_configuration(tenant, consumption_version="a0392d4f-72a9-...") + +# pick from the list +versions = client.get_consumption_versions(tenant) +cv = versions.latest() # or versions.items[0], or your own selection logic +config = client.get_configuration(tenant, consumption_version=cv.version) +``` + +### ConfigData structure + +``` +ConfigData +├── consumption_version: str # e.g. "a0392d4f-72a9-..." +├── tenant_context: TenantContext +└── 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. + +```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` | missing or incomplete credentials at startup | + +```python +from sap_cloud_sdk.cbc import CBCClientError, CBCServerError, CBCNetworkError + +try: + config = client.get_configuration(tenant) +except CBCClientError as e: + print(e.code, e.message) # e.g. "NOT_FOUND", "Tenant unknown" +except CBCNetworkError: + ... # retry / circuit-break +``` + +## Environment variables + +| Variable | Required | Description | +|---|---|---| +| `CLOUD_SDK_CBC_URL` | yes | Base URL of the CBC service | +| `CLOUD_SDK_CBC_CERT_PATH` | prod only | Path to the mTLS client certificate (PEM) | +| `CLOUD_SDK_CBC_KEY_PATH` | prod only | Path to the mTLS private key (PEM) | +| `CLOUD_SDK_CBC_REPLACE_SUBDOMAIN` | no | Override subdomain replacement (`true`/`false`). Auto-detected from URL when unset. | + +Local mode (loopback URL) requires only `CLOUD_SDK_CBC_URL`. + +## Using a test double + +`CBCClient` is a `Protocol` — implement it directly in tests: + +```python +from sap_cloud_sdk.cbc import CBCClient, ConfigData, TenantContext + +class StubCBCClient: + def get_consumption_versions(self, tenant_context): + ... + def get_configuration(self, tenant_context, consumption_version=None): + return ConfigData( + consumption_version="cv1", + tenant_context=tenant_context, + 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..442c4815 --- /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 tenant_context 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..4603f9d7 --- /dev/null +++ b/tests/cbc/integration/conftest.py @@ -0,0 +1,44 @@ +"""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_CBC_TENANT_ID CBC tenant ID for subdomain routing (required) + CLOUD_SDK_CBC_APP_TENANT_ID Application tenant ID (required) + CLOUD_SDK_CBC_CERT_PATH Path to mTLS client certificate (optional) + CLOUD_SDK_CBC_KEY_PATH Path to mTLS private key (optional) + CLOUD_SDK_CBC_REPLACE_SUBDOMAIN Override subdomain replacement (optional) + +When any required variable is missing, integration tests are skipped. +""" + +from __future__ import annotations + +import os + +import pytest + +from sap_cloud_sdk.cbc import DefaultClient, TenantContext, create_client +from sap_cloud_sdk.cbc.exceptions import CBCConfigError + +ENV_CBC_TENANT_ID = "CLOUD_SDK_CBC_CBC_TENANT_ID" +ENV_APP_TENANT_ID = "CLOUD_SDK_CBC_APP_TENANT_ID" + + +@pytest.fixture(scope="session") +def cbc_tenant() -> TenantContext: + cbc_tid = os.environ.get(ENV_CBC_TENANT_ID) + app_tid = os.environ.get(ENV_APP_TENANT_ID) + if not cbc_tid or not app_tid: + pytest.skip( + f"CBC integration tests skipped — set {ENV_CBC_TENANT_ID} and {ENV_APP_TENANT_ID}." + ) + return TenantContext(cbcTenantId=cbc_tid, appTenantId=app_tid) + + +@pytest.fixture(scope="session") +def cbc_client() -> DefaultClient: + try: + return create_client() + except CBCConfigError as exc: + pytest.skip(f"CBC integration tests skipped — missing config: {exc}") diff --git a/tests/cbc/integration/test_e2e_bdd.py b/tests/cbc/integration/test_e2e_bdd.py new file mode 100644 index 00000000..7172cf37 --- /dev/null +++ b/tests/cbc/integration/test_e2e_bdd.py @@ -0,0 +1,141 @@ +"""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_CBC_TENANT_ID=my-cbc-tenant \\ + CLOUD_SDK_CBC_APP_TENANT_ID=my-app-tenant \\ + pytest tests/cbc/integration + +Or against production (with mTLS):: + + CLOUD_SDK_CBC_URL=https://cbc.example.ondemand.com \\ + CLOUD_SDK_CBC_CERT_PATH=/run/secrets/tls.crt \\ + CLOUD_SDK_CBC_KEY_PATH=/run/secrets/tls.key \\ + CLOUD_SDK_CBC_CBC_TENANT_ID=my-cbc-tenant \\ + 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, TenantContext +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_tenant: TenantContext): + pass + + +@when("I call get_consumption_versions") +def call_get_consumption_versions( + ctx: dict, cbc_client: DefaultClient, cbc_tenant: TenantContext +): + ctx["versions"] = cbc_client.get_consumption_versions(cbc_tenant) + + +@when("I call get_configuration") +def call_get_configuration( + ctx: dict, cbc_client: DefaultClient, cbc_tenant: TenantContext +): + ctx["config"] = cbc_client.get_configuration(cbc_tenant) + + +@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 tenant_context should match the configured tenant") +def assert_tenant_context(ctx: dict, cbc_tenant: TenantContext): + config: ConfigData = ctx["config"] + assert config.tenant_context == cbc_tenant + + +@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..28787c41 --- /dev/null +++ b/tests/cbc/unit/test_client.py @@ -0,0 +1,295 @@ +"""Unit tests for DefaultClient and client_from_env.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import httpx +import json +import pytest + +from sap_cloud_sdk.cbc.client import DefaultClient, create_client +from sap_cloud_sdk.cbc.config import ENV_URL, ENV_CERT_PATH, ENV_KEY_PATH +from sap_cloud_sdk.cbc.exceptions import CBCClientError, CBCConfigError, CBCNetworkError, CBCServerError +from sap_cloud_sdk.cbc._models import ( + ConfigData, + TenantContext, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _tenant(cbc: str = "cbc-tenant", app: str = "app-tenant") -> TenantContext: + return TenantContext(cbcTenantId=cbc, appTenantId=app) + + +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") -> tuple[DefaultClient, MagicMock]: + mock_http = MagicMock(spec=httpx.Client) + client = DefaultClient(base_url=base_url, http_client=mock_http) + return client, mock_http + + +# --------------------------------------------------------------------------- +# DefaultClient — URL detection +# --------------------------------------------------------------------------- + + +class TestDefaultClientLocalMode: + def test_loopback_localhost_disables_subdomain_replacement(self): + client, _ = _make_client("http://localhost:8001") + assert not client._config.replace_subdomain + + def test_loopback_127_disables_subdomain_replacement(self): + client, _ = _make_client("http://127.0.0.1:8001") + assert not client._config.replace_subdomain + + def test_production_url_enables_subdomain_replacement(self): + client, _ = _make_client("https://cbc.example.ondemand.com") + assert client._config.replace_subdomain + + +# --------------------------------------------------------------------------- +# DefaultClient — URL building +# --------------------------------------------------------------------------- + + +class TestConfigurationsUrl: + def test_production_replaces_subdomain_with_tenant(self): + client, _ = _make_client("https://cbc.example.ondemand.com") + url = client._configurations_url(_tenant("my-tenant"), "/consumptionVersions") + assert url.startswith("https://my-tenant.") + + def test_local_does_not_replace_subdomain(self): + client, _ = _make_client("http://localhost:8001") + url = client._configurations_url(_tenant("my-tenant"), "/consumptionVersions") + assert "localhost:8001" in url + assert "my-tenant" not in url.split("//")[1].split("/")[0] + + +# --------------------------------------------------------------------------- +# 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(_tenant()) + 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(_tenant()) + + 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(_tenant()) + + 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(_tenant()) + + +# --------------------------------------------------------------------------- +# DefaultClient — _get_entities +# --------------------------------------------------------------------------- + + +class TestGetEntities: + def test_returns_parsed_entities(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={ + "items": [ + { + "entityId": "i1", + "entityName": "payment-mode", + "configurationObjectId": "payment-config", + } + ] + } + ) + result = client._get_entities(_tenant(), "cv1") + assert len(result.items) == 1 + assert result.items[0].internal_id == "i1" + + +# --------------------------------------------------------------------------- +# DefaultClient — _get_entity_data +# --------------------------------------------------------------------------- + + +class TestGetEntityData: + def test_returns_entity_data_with_entity_id(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={"items": [{"key": "value"}]} + ) + + result = client._get_entity_data(_tenant(), "cv1", "e1") + assert result.entity_id == "e1" + assert result.data.as_list() == [{"key": "value"}] + + def test_uses_api_metadata_entity_name_when_present(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={ + "metadata": { + "entityName": "rules", + "configurationObjectId": "qualification-rules", + }, + "items": [{"key": "value"}], + } + ) + + result = client._get_entity_data(_tenant(), "cv1", "e1") + assert result.entity_id == "rules" + assert result.data.as_list() == [{"key": "value"}] + + def test_handles_flat_list_response(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response(json_body=[{"row": 1}]) + + result = client._get_entity_data(_tenant(), "cv1", "e1") + assert result.data.as_list() == [{"row": 1}] + + +# --------------------------------------------------------------------------- +# 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"}]} + ) + entities_response = _mock_response(json_body={"items": []}) + mock_http.request.side_effect = [versions_response, entities_response] + + result = client.get_configuration(_tenant()) + 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(_tenant()) + + def test_uses_explicit_consumption_version(self): + client, mock_http = _make_client() + entities_response = _mock_response( + json_body={ + "items": [ + {"entityId": "i1", "entityName": "payment-mode", "configurationObjectId": "payment-config"} + ] + } + ) + data_response = _mock_response(json_body={"items": [{"k": "v"}]}) + mock_http.request.side_effect = [entities_response, data_response] + + result = client.get_configuration(_tenant(), 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 + + +# --------------------------------------------------------------------------- +# 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 / load_from_env +# --------------------------------------------------------------------------- + + +class TestCreateClient: + def test_raises_config_error_when_no_env_vars(self, monkeypatch): + monkeypatch.delenv(ENV_URL, raising=False) + monkeypatch.delenv(ENV_CERT_PATH, raising=False) + monkeypatch.delenv(ENV_KEY_PATH, raising=False) + with pytest.raises(CBCConfigError): + create_client() + + def test_returns_client_for_loopback_url(self, monkeypatch): + monkeypatch.setenv(ENV_URL, "http://localhost:8001") + monkeypatch.delenv(ENV_CERT_PATH, raising=False) + monkeypatch.delenv(ENV_KEY_PATH, raising=False) + client = create_client() + assert isinstance(client, DefaultClient) + + def test_raises_config_error_for_incomplete_triplet(self, monkeypatch, tmp_path): + cert = tmp_path / "tls.crt" + cert.write_text("cert") + monkeypatch.setenv(ENV_CERT_PATH, str(cert)) + monkeypatch.delenv(ENV_KEY_PATH, raising=False) + monkeypatch.delenv(ENV_URL, raising=False) + with pytest.raises(CBCConfigError, match="incomplete"): + create_client() + + def test_raises_config_error_for_missing_cert_file(self, monkeypatch, tmp_path): + monkeypatch.setenv(ENV_CERT_PATH, str(tmp_path / "missing.crt")) + with pytest.raises(CBCConfigError, match="does not exist"): + create_client() + + def test_returns_client_with_env_var_cert_triplet(self, monkeypatch, tmp_path): + cert = tmp_path / "tls.crt" + key = tmp_path / "tls.key" + cert.write_text("cert") + key.write_text("key") + monkeypatch.setenv(ENV_CERT_PATH, str(cert)) + monkeypatch.setenv(ENV_KEY_PATH, str(key)) + monkeypatch.setenv(ENV_URL, "https://cbc.example.ondemand.com") + client = create_client() + assert isinstance(client, DefaultClient) + + def test_accepts_explicit_config(self): + from sap_cloud_sdk.cbc.config import CBCConfig + + cfg = CBCConfig(base_url="http://localhost:9000") + client = create_client(config=cfg) + assert isinstance(client, DefaultClient) diff --git a/tests/cbc/unit/test_config.py b/tests/cbc/unit/test_config.py new file mode 100644 index 00000000..44b5f5e3 --- /dev/null +++ b/tests/cbc/unit/test_config.py @@ -0,0 +1,86 @@ +"""Unit tests for CBC config resolution.""" + +from __future__ import annotations + +import pytest + +from sap_cloud_sdk.cbc.config import ( + ENV_CERT_PATH, + ENV_KEY_PATH, + ENV_URL, + _read_env_path, + load_from_env, +) +from sap_cloud_sdk.cbc.exceptions import CBCConfigError + + +# --------------------------------------------------------------------------- +# load_from_env +# --------------------------------------------------------------------------- + + +class TestLoadFromEnv: + def test_raises_when_no_env_vars(self, monkeypatch): + monkeypatch.delenv(ENV_URL, raising=False) + monkeypatch.delenv(ENV_CERT_PATH, raising=False) + monkeypatch.delenv(ENV_KEY_PATH, raising=False) + with pytest.raises(CBCConfigError): + load_from_env() + + def test_returns_config_for_url_only(self, monkeypatch): + monkeypatch.setenv(ENV_URL, "http://localhost:8001") + monkeypatch.delenv(ENV_CERT_PATH, raising=False) + monkeypatch.delenv(ENV_KEY_PATH, raising=False) + cfg = load_from_env() + assert cfg.base_url == "http://localhost:8001" + assert cfg.cert_path is None + assert cfg.key_path is None + + def test_returns_config_with_cert_triplet(self, monkeypatch, tmp_path): + cert = tmp_path / "tls.crt" + key = tmp_path / "tls.key" + cert.write_text("cert") + key.write_text("key") + monkeypatch.setenv(ENV_CERT_PATH, str(cert)) + monkeypatch.setenv(ENV_KEY_PATH, str(key)) + monkeypatch.setenv(ENV_URL, "https://cbc.example.ondemand.com") + cfg = load_from_env() + assert cfg.base_url == "https://cbc.example.ondemand.com" + assert cfg.cert_path == cert + assert cfg.key_path == key + + def test_raises_for_incomplete_triplet(self, monkeypatch, tmp_path): + cert = tmp_path / "tls.crt" + cert.write_text("cert") + monkeypatch.setenv(ENV_CERT_PATH, str(cert)) + monkeypatch.delenv(ENV_KEY_PATH, raising=False) + monkeypatch.delenv(ENV_URL, raising=False) + with pytest.raises(CBCConfigError, match="incomplete"): + load_from_env() + + def test_raises_for_missing_cert_file(self, monkeypatch, tmp_path): + monkeypatch.setenv(ENV_CERT_PATH, str(tmp_path / "missing.crt")) + with pytest.raises(CBCConfigError, match="does not exist"): + load_from_env() + + +# --------------------------------------------------------------------------- +# _read_env_path +# --------------------------------------------------------------------------- + + +class TestReadEnvPath: + def test_returns_none_when_unset(self, monkeypatch): + monkeypatch.delenv("MY_PATH", raising=False) + assert _read_env_path("MY_PATH") is None + + def test_returns_path_when_file_exists(self, monkeypatch, tmp_path): + p = tmp_path / "file.pem" + p.write_text("x") + monkeypatch.setenv("MY_PATH", str(p)) + assert _read_env_path("MY_PATH") == p + + def test_raises_when_file_missing(self, monkeypatch, tmp_path): + monkeypatch.setenv("MY_PATH", str(tmp_path / "missing.pem")) + with pytest.raises(CBCConfigError, match="does not exist"): + _read_env_path("MY_PATH") diff --git a/tests/cbc/unit/test_models.py b/tests/cbc/unit/test_models.py new file mode 100644 index 00000000..2f2d7348 --- /dev/null +++ b/tests/cbc/unit/test_models.py @@ -0,0 +1,183 @@ +"""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, + TenantContext, +) + + +# --------------------------------------------------------------------------- +# TenantContext +# --------------------------------------------------------------------------- + + +class TestTenantContext: + def test_accepts_camel_case_aliases(self): + ctx = TenantContext(cbcTenantId="cbc-1", appTenantId="app-1") + assert ctx.cbc_tenant_id == "cbc-1" + assert ctx.app_tenant_id == "app-1" + + def test_accepts_snake_case_names(self): + ctx = TenantContext(cbc_tenant_id="cbc-1", app_tenant_id="app-1") + assert ctx.cbc_tenant_id == "cbc-1" + + def test_rejects_empty_cbc_tenant_id(self): + with pytest.raises(Exception): + TenantContext(cbcTenantId="", appTenantId="app-1") + + +# --------------------------------------------------------------------------- +# 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), + ] + ) + assert v.latest().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), + ] + ) + assert v.latest().version == "v2" + + def test_returns_last_item_when_no_dates(self): + v = ConsumptionVersions( + items=[self._version("v1"), self._version("v2")] + ) + assert v.latest().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() + + +# --------------------------------------------------------------------------- +# 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", + tenant_context=TenantContext(cbcTenantId="t1", appTenantId="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 From d11de80c242e57971a002fe78c92f0e1b2a5b9b4 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Mon, 14 Sep 2026 19:39:23 +0530 Subject: [PATCH 02/14] feat(cbc): support PEM cert values via env vars and cert_path/key_path params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the cert tuple parameter with symmetric cert_path/key_path params. Add CLOUD_SDK_CBC_CERT / CLOUD_SDK_CBC_KEY env vars so PEM values can be supplied directly (e.g. from K8s secrets) without writing to disk first — create_client() handles the temp-file lifecycle automatically. --- src/sap_cloud_sdk/cbc/client.py | 32 ++++++++--------- src/sap_cloud_sdk/cbc/config.py | 54 ++++++++++++++++++++++------- src/sap_cloud_sdk/cbc/user-guide.md | 8 +++-- tests/cbc/unit/test_config.py | 20 +++++++++++ 4 files changed, 83 insertions(+), 31 deletions(-) diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index 6dc39d24..76298dcd 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -117,8 +117,8 @@ class DefaultClient: **Production** (any ``https://`` or non-loopback URL): subdomain-per-tenant routing rewrites the URL subdomain to the ``cbc_tenant_id`` for each request; - mTLS credentials must be provided via ``cert``, ``cert_pem``/``key_pem``, or - ``ssl_context``. + mTLS credentials must be provided via ``cert_path``/``key_path``, + ``cert_pem``/``key_pem``, or ``ssl_context``. **Local / mock** (``http://localhost``, ``http://127.0.0.1``, ``http://[::1]``): no subdomain replacement, no mTLS — detected automatically from the URL. @@ -138,7 +138,8 @@ class DefaultClient: client = DefaultClient( base_url="https://cbc.example.ondemand.com", - cert=(Path("/run/secrets/tls.crt"), Path("/run/secrets/tls.key")), + cert_path=Path("/run/secrets/tls.crt"), + key_path=Path("/run/secrets/tls.key"), ) Args: @@ -147,10 +148,10 @@ class DefaultClient: http_client: Optional pre-configured ``httpx.Client`` — takes full precedence over all mTLS arguments. Use for testing. ssl_context: Optional pre-built :class:`ssl.SSLContext` with mTLS loaded. - cert: ``(cert_path, key_path)`` tuple of :class:`pathlib.Path` objects. - cert_pem: Raw PEM string for the client certificate. Requires - ``key_pem`` to also be set. Written to a temp file deleted after - the first connection. + cert_path: Path to the PEM client certificate file. Requires ``key_path``. + key_path: Path to the PEM private key file. Requires ``cert_path``. + cert_pem: Raw PEM string for the client certificate. Requires ``key_pem``. + Written to a temp file deleted after the first connection. key_pem: Raw PEM string for the private key. Requires ``cert_pem``. """ @@ -159,7 +160,8 @@ def __init__( base_url: str, http_client: httpx.Client | None = None, ssl_context: ssl.SSLContext | None = None, - cert: tuple[Path, Path] | None = None, + cert_path: Path | None = None, + key_path: Path | None = None, cert_pem: str | None = None, key_pem: str | None = None, replace_subdomain: bool | None = None, @@ -176,8 +178,8 @@ def __init__( ) if http_client is None and ssl_context is None: - if cert is not None: - transport = _LazyCertTransport(str(cert[0]), str(cert[1])) + if cert_path is not None and key_path is not None: + transport = _LazyCertTransport(str(cert_path), str(key_path)) http_client = httpx.Client(transport=transport) elif cert_pem is not None and key_pem is not None: with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as cf: @@ -444,13 +446,11 @@ def create_client(*, config: CBCConfig | None = None) -> CBCClient: from sap_cloud_sdk.cbc.config import load_from_env resolved: CBCConfig = config if config is not None else load_from_env() - cert = ( - (resolved.cert_path, resolved.key_path) - if resolved.cert_path and resolved.key_path - else None - ) return DefaultClient( base_url=resolved.base_url, - cert=cert, + cert_path=resolved.cert_path, + key_path=resolved.key_path, + cert_pem=resolved.cert_pem, + key_pem=resolved.key_pem, replace_subdomain=resolved.replace_subdomain, ) diff --git a/src/sap_cloud_sdk/cbc/config.py b/src/sap_cloud_sdk/cbc/config.py index 2ae1a1cf..7f151d99 100644 --- a/src/sap_cloud_sdk/cbc/config.py +++ b/src/sap_cloud_sdk/cbc/config.py @@ -7,6 +7,8 @@ CLOUD_SDK_CBC_URL CBC service base URL (required) CLOUD_SDK_CBC_CERT_PATH Path to PEM client certificate file CLOUD_SDK_CBC_KEY_PATH Path to PEM private key file + CLOUD_SDK_CBC_CERT PEM client certificate value (alternative to CERT_PATH) + CLOUD_SDK_CBC_KEY PEM private key value (alternative to KEY_PATH) """ from __future__ import annotations @@ -20,6 +22,8 @@ ENV_URL = "CLOUD_SDK_CBC_URL" ENV_CERT_PATH = "CLOUD_SDK_CBC_CERT_PATH" ENV_KEY_PATH = "CLOUD_SDK_CBC_KEY_PATH" +ENV_CERT = "CLOUD_SDK_CBC_CERT" +ENV_KEY = "CLOUD_SDK_CBC_KEY" ENV_REPLACE_SUBDOMAIN = "CLOUD_SDK_CBC_REPLACE_SUBDOMAIN" @@ -31,6 +35,8 @@ class CBCConfig: base_url: CBC service base URL. cert_path: Path to the PEM client certificate file, or ``None`` for local/mock mode. key_path: Path to the PEM private key file, or ``None`` for local/mock mode. + cert_pem: PEM client certificate value. Alternative to ``cert_path``. + key_pem: PEM private key value. Alternative to ``key_path``. replace_subdomain: Whether to rewrite the URL subdomain to the CBC tenant ID on each request. ``None`` (default) auto-detects: loopback URLs disable it, all others enable it. Set explicitly to ``False`` for HTTPS mock servers. @@ -39,6 +45,8 @@ class CBCConfig: base_url: str cert_path: Path | None = None key_path: Path | None = None + cert_pem: str | None = None + key_pem: str | None = None replace_subdomain: bool | None = None @@ -47,10 +55,13 @@ def load_from_env() -> CBCConfig: Resolution order (first match wins): - 1. **Credential triplet** — ``CLOUD_SDK_CBC_CERT_PATH``, - ``CLOUD_SDK_CBC_KEY_PATH``, and ``CLOUD_SDK_CBC_URL`` must all be set. - The path vars must point to existing PEM files. - 2. **URL only** — loopback addresses (``http://localhost``, + 1. **Path triplet** — ``CLOUD_SDK_CBC_CERT_PATH``, ``CLOUD_SDK_CBC_KEY_PATH``, + and ``CLOUD_SDK_CBC_URL`` must all be set. The path vars must point to + existing PEM files. + 2. **Value triplet** — ``CLOUD_SDK_CBC_CERT``, ``CLOUD_SDK_CBC_KEY``, and + ``CLOUD_SDK_CBC_URL`` must all be set. PEM values are written to temp + files deleted after the first connection. + 3. **URL only** — loopback addresses (``http://localhost``, ``http://127.0.0.1``) trigger local/mock mode (no mTLS, no subdomain replacement). Non-loopback URLs produce a client without mTLS. @@ -63,28 +74,45 @@ def load_from_env() -> CBCConfig: path env var points to a non-existent file. """ url = os.environ.get(ENV_URL) + replace_subdomain = _read_env_bool(ENV_REPLACE_SUBDOMAIN) - cert = _read_env_path(ENV_CERT_PATH) - key = _read_env_path(ENV_KEY_PATH) - if cert and key and url: + cert_path = _read_env_path(ENV_CERT_PATH) + key_path = _read_env_path(ENV_KEY_PATH) + if cert_path and key_path and url: return CBCConfig( base_url=url, - cert_path=cert, - key_path=key, - replace_subdomain=_read_env_bool(ENV_REPLACE_SUBDOMAIN), + cert_path=cert_path, + key_path=key_path, + replace_subdomain=replace_subdomain, ) - if cert or key: + if cert_path or key_path: raise CBCConfigError( "CBC env-var credential triplet is incomplete. " f"Set all of {ENV_CERT_PATH}, {ENV_KEY_PATH}, and {ENV_URL} — or none." ) + cert_pem = os.environ.get(ENV_CERT) + key_pem = os.environ.get(ENV_KEY) + if cert_pem and key_pem and url: + return CBCConfig( + base_url=url, + cert_pem=cert_pem, + key_pem=key_pem, + replace_subdomain=replace_subdomain, + ) + if cert_pem or key_pem: + raise CBCConfigError( + "CBC env-var credential pair is incomplete. " + f"Set both {ENV_CERT} and {ENV_KEY} together with {ENV_URL} — or none." + ) + if url: - return CBCConfig(base_url=url, replace_subdomain=_read_env_bool(ENV_REPLACE_SUBDOMAIN)) + return CBCConfig(base_url=url, replace_subdomain=replace_subdomain) raise CBCConfigError( f"No CBC configuration found. Set {ENV_URL} at minimum, " - f"or provide mTLS credentials via {ENV_CERT_PATH} / {ENV_KEY_PATH}." + f"or provide mTLS credentials via {ENV_CERT_PATH} / {ENV_KEY_PATH} " + f"or {ENV_CERT} / {ENV_KEY}." ) diff --git a/src/sap_cloud_sdk/cbc/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index 7895e441..5e954098 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -134,12 +134,16 @@ except CBCNetworkError: | Variable | Required | Description | |---|---|---| | `CLOUD_SDK_CBC_URL` | yes | Base URL of the CBC service | -| `CLOUD_SDK_CBC_CERT_PATH` | prod only | Path to the mTLS client certificate (PEM) | -| `CLOUD_SDK_CBC_KEY_PATH` | prod only | Path to the mTLS private key (PEM) | +| `CLOUD_SDK_CBC_CERT_PATH` | prod only | Path to the mTLS client certificate (PEM file) | +| `CLOUD_SDK_CBC_KEY_PATH` | prod only | Path to the mTLS private key (PEM file) | +| `CLOUD_SDK_CBC_CERT` | prod only | mTLS client certificate value (PEM string, alternative to `CERT_PATH`) | +| `CLOUD_SDK_CBC_KEY` | prod only | mTLS private key value (PEM string, alternative to `KEY_PATH`) | | `CLOUD_SDK_CBC_REPLACE_SUBDOMAIN` | no | Override subdomain replacement (`true`/`false`). Auto-detected from URL when unset. | Local mode (loopback URL) requires only `CLOUD_SDK_CBC_URL`. +`CERT_PATH`/`KEY_PATH` (file paths) take precedence over `CERT`/`KEY` (values) when both are set. + ## Using a test double `CBCClient` is a `Protocol` — implement it directly in tests: diff --git a/tests/cbc/unit/test_config.py b/tests/cbc/unit/test_config.py index 44b5f5e3..9869b1c7 100644 --- a/tests/cbc/unit/test_config.py +++ b/tests/cbc/unit/test_config.py @@ -5,7 +5,9 @@ import pytest from sap_cloud_sdk.cbc.config import ( + ENV_CERT, ENV_CERT_PATH, + ENV_KEY, ENV_KEY_PATH, ENV_URL, _read_env_path, @@ -58,6 +60,24 @@ def test_raises_for_incomplete_triplet(self, monkeypatch, tmp_path): with pytest.raises(CBCConfigError, match="incomplete"): load_from_env() + def test_raises_for_incomplete_cert_pem_pair(self, monkeypatch): + monkeypatch.setenv(ENV_CERT, "-----BEGIN CERTIFICATE-----") + monkeypatch.delenv(ENV_KEY, raising=False) + monkeypatch.setenv(ENV_URL, "https://cbc.example.ondemand.com") + with pytest.raises(CBCConfigError, match="incomplete"): + load_from_env() + + def test_returns_config_with_cert_pem_pair(self, monkeypatch): + monkeypatch.setenv(ENV_CERT, "-----BEGIN CERTIFICATE-----") + monkeypatch.setenv(ENV_KEY, "-----BEGIN PRIVATE KEY-----") + monkeypatch.setenv(ENV_URL, "https://cbc.example.ondemand.com") + monkeypatch.delenv(ENV_CERT_PATH, raising=False) + monkeypatch.delenv(ENV_KEY_PATH, raising=False) + cfg = load_from_env() + assert cfg.cert_pem == "-----BEGIN CERTIFICATE-----" + assert cfg.key_pem == "-----BEGIN PRIVATE KEY-----" + assert cfg.cert_path is None + def test_raises_for_missing_cert_file(self, monkeypatch, tmp_path): monkeypatch.setenv(ENV_CERT_PATH, str(tmp_path / "missing.crt")) with pytest.raises(CBCConfigError, match="does not exist"): From 45cb6453d1d6cafc7e7933526385bd08048ca99f Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Mon, 14 Sep 2026 20:57:22 +0530 Subject: [PATCH 03/14] =?UTF-8?q?fix(cbc):=20address=20CI=20failures=20?= =?UTF-8?q?=E2=80=94=20ruff=20format,=20ty=20errors,=20telemetry=20counts,?= =?UTF-8?q?=20version=20bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump version to 0.54.0 (required by CI for src/ changes) - Fix ruff format violations in _models.py and client.py - Fix ty errors: conftest fixture return type CBCClient, test_models assert-not-None before .version - Update test_module (15→16) and test_operation (161→163) counts for CBC module/operations --- src/sap_cloud_sdk/cbc/_models.py | 7 +++++-- src/sap_cloud_sdk/cbc/client.py | 13 +++++++------ tests/cbc/integration/conftest.py | 4 ++-- tests/cbc/integration/test_e2e_bdd.py | 8 ++++++-- tests/cbc/unit/test_client.py | 21 +++++++++++++++------ tests/cbc/unit/test_models.py | 16 ++++++++++------ tests/core/unit/telemetry/test_module.py | 1 + tests/core/unit/telemetry/test_operation.py | 5 +++-- 8 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/sap_cloud_sdk/cbc/_models.py b/src/sap_cloud_sdk/cbc/_models.py index d68def49..3fdff586 100644 --- a/src/sap_cloud_sdk/cbc/_models.py +++ b/src/sap_cloud_sdk/cbc/_models.py @@ -132,7 +132,6 @@ class Entities(_FrozenModel): items: list[Entity] - class EntityContent: """Configuration content for an entity. @@ -231,7 +230,11 @@ def get_config_object(self, config_object_id: str) -> ConfigObject | None: 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), + ( + co + for co in self.config_objects + if co.config_object_id == config_object_id + ), None, ) diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index 76298dcd..77af6158 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -106,7 +106,6 @@ class _ClientConfig: replace_subdomain: bool - # --------------------------------------------------------------------------- # DefaultClient # --------------------------------------------------------------------------- @@ -230,9 +229,7 @@ def get_consumption_versions( tenant_context, f"/consumptionVersions?appTenantId={tenant_context.app_tenant_id}", ) - return ConsumptionVersions.model_validate( - self._request("GET", url).json() - ) + return ConsumptionVersions.model_validate(self._request("GET", url).json()) def _get_entities( self, tenant_context: TenantContext, consumption_version: str @@ -365,7 +362,9 @@ def _fetch_entity_data( ) response_data = self._request("GET", url).json() - api_meta = response_data.get("metadata", {}) if isinstance(response_data, dict) else {} + api_meta = ( + response_data.get("metadata", {}) if isinstance(response_data, dict) else {} + ) raw_data = ( response_data["items"] if isinstance(response_data, dict) and "items" in response_data @@ -414,7 +413,9 @@ def _request( request_url=url, ) error = ApiError.from_response(response.content) - exc_class = CBCServerError if response.status_code >= 500 else CBCClientError + exc_class = ( + CBCServerError if response.status_code >= 500 else CBCClientError + ) raise exc_class(error.message, code=error.code, http_context=ctx) return response diff --git a/tests/cbc/integration/conftest.py b/tests/cbc/integration/conftest.py index 4603f9d7..005c299c 100644 --- a/tests/cbc/integration/conftest.py +++ b/tests/cbc/integration/conftest.py @@ -18,7 +18,7 @@ import pytest -from sap_cloud_sdk.cbc import DefaultClient, TenantContext, create_client +from sap_cloud_sdk.cbc import CBCClient, TenantContext, create_client from sap_cloud_sdk.cbc.exceptions import CBCConfigError ENV_CBC_TENANT_ID = "CLOUD_SDK_CBC_CBC_TENANT_ID" @@ -37,7 +37,7 @@ def cbc_tenant() -> TenantContext: @pytest.fixture(scope="session") -def cbc_client() -> DefaultClient: +def cbc_client() -> CBCClient: try: return create_client() except CBCConfigError as exc: diff --git a/tests/cbc/integration/test_e2e_bdd.py b/tests/cbc/integration/test_e2e_bdd.py index 7172cf37..1c03f522 100644 --- a/tests/cbc/integration/test_e2e_bdd.py +++ b/tests/cbc/integration/test_e2e_bdd.py @@ -59,7 +59,9 @@ def test_configuration_has_config_objects(): pass -@scenario("cbc.feature", "Every entity within each config object has an entity_id and data") +@scenario( + "cbc.feature", "Every entity within each config object has an entity_id and data" +) def test_every_entity_has_id_and_data(): pass @@ -124,7 +126,9 @@ 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}" + 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") diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index 28787c41..2bf1f616 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -11,7 +11,12 @@ from sap_cloud_sdk.cbc.client import DefaultClient, create_client from sap_cloud_sdk.cbc.config import ENV_URL, ENV_CERT_PATH, ENV_KEY_PATH -from sap_cloud_sdk.cbc.exceptions import CBCClientError, CBCConfigError, CBCNetworkError, CBCServerError +from sap_cloud_sdk.cbc.exceptions import ( + CBCClientError, + CBCConfigError, + CBCNetworkError, + CBCServerError, +) from sap_cloud_sdk.cbc._models import ( ConfigData, TenantContext, @@ -42,7 +47,9 @@ def _mock_response( ) -def _make_client(base_url: str = "https://cbc.example.ondemand.com") -> tuple[DefaultClient, MagicMock]: +def _make_client( + base_url: str = "https://cbc.example.ondemand.com", +) -> tuple[DefaultClient, MagicMock]: mock_http = MagicMock(spec=httpx.Client) client = DefaultClient(base_url=base_url, http_client=mock_http) return client, mock_http @@ -194,9 +201,7 @@ def test_handles_flat_list_response(self): 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"}]} - ) + versions_response = _mock_response(json_body={"items": [{"version": "v2"}]}) entities_response = _mock_response(json_body={"items": []}) mock_http.request.side_effect = [versions_response, entities_response] @@ -216,7 +221,11 @@ def test_uses_explicit_consumption_version(self): entities_response = _mock_response( json_body={ "items": [ - {"entityId": "i1", "entityName": "payment-mode", "configurationObjectId": "payment-config"} + { + "entityId": "i1", + "entityName": "payment-mode", + "configurationObjectId": "payment-config", + } ] } ) diff --git a/tests/cbc/unit/test_models.py b/tests/cbc/unit/test_models.py index 2f2d7348..85e9f267 100644 --- a/tests/cbc/unit/test_models.py +++ b/tests/cbc/unit/test_models.py @@ -68,7 +68,9 @@ def test_returns_latest_by_modified_date(self): self._version("v2", modified=t2), ] ) - assert v.latest().version == "v2" + 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) @@ -79,13 +81,15 @@ def test_returns_latest_by_created_date_when_no_modified(self): self._version("v2", created=t2), ] ) - assert v.latest().version == "v2" + 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")] - ) - assert v.latest().version == "v2" + v = ConsumptionVersions(items=[self._version("v1"), self._version("v2")]) + result = v.latest() + assert result is not None + assert result.version == "v2" # --------------------------------------------------------------------------- diff --git a/tests/core/unit/telemetry/test_module.py b/tests/core/unit/telemetry/test_module.py index 6d38697a..73174a67 100644 --- a/tests/core/unit/telemetry/test_module.py +++ b/tests/core/unit/telemetry/test_module.py @@ -60,6 +60,7 @@ def test_all_modules_present(self): all_modules = list(Module) assert len(all_modules) == 16 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 From 795882677930a8fb2db7e54a31523d3688a43470 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Wed, 16 Sep 2026 10:06:15 +0530 Subject: [PATCH 04/14] fix(cbc): clean up DefaultClient docstring and gitignore - Soften "do not instantiate" to "prefer create_client" - Replace contradictory direct-instantiation examples with create_client usage - Reference BTP Destination Service and env vars as credential sources - Add tmp/ to .gitignore --- .gitignore | 3 +++ src/sap_cloud_sdk/cbc/client.py | 37 +++++++++++---------------------- 2 files changed, 15 insertions(+), 25 deletions(-) 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/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index 77af6158..08bc3981 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -112,38 +112,25 @@ class _ClientConfig: class DefaultClient: - """CBC client for both production (mTLS + envoy) and local/mock environments. + """CBC client implementation. - **Production** (any ``https://`` or non-loopback URL): subdomain-per-tenant - routing rewrites the URL subdomain to the ``cbc_tenant_id`` for each request; - mTLS credentials must be provided via ``cert_path``/``key_path``, - ``cert_pem``/``key_pem``, or ``ssl_context``. + Prefer :func:`create_client` over direct instantiation — it resolves + credentials automatically (BTP Destination Service or environment variables, + or accepts an explicit :class:`~sap_cloud_sdk.cbc.config.CBCConfig`):: - **Local / mock** (``http://localhost``, ``http://127.0.0.1``, ``http://[::1]``): - no subdomain replacement, no mTLS — detected automatically from the URL. - Point it at the CBC mock server and it works without any extra arguments. + client = create_client() - Do **not** instantiate directly — use :func:`create_client` in production - code, which resolves credentials from the environment automatically. - - Example (local mock):: - - client = DefaultClient(base_url="http://localhost:8001") - config = client.get_configuration( - TenantContext(cbcTenantId="t1", appTenantId="app-t1") - ) - - Example (production):: - - client = DefaultClient( - base_url="https://cbc.example.ondemand.com", + # explicit config + client = create_client(config=CBCConfig( + base_url="https://service.app.prod-eu.cbc.services.cloud.sap", cert_path=Path("/run/secrets/tls.crt"), key_path=Path("/run/secrets/tls.key"), - ) + )) + + Direct instantiation is supported for testing (inject a mock ``http_client``). Args: - base_url: Base URL of the CBC service. Loopback addresses trigger - local mode automatically. + base_url: Base URL of the CBC service. http_client: Optional pre-configured ``httpx.Client`` — takes full precedence over all mTLS arguments. Use for testing. ssl_context: Optional pre-built :class:`ssl.SSLContext` with mTLS loaded. From a291c8ce852c5cfc5d3884608e8add6661fe2587 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Fri, 18 Sep 2026 23:10:41 +0530 Subject: [PATCH 05/14] refactor(cbc): remove local-mode auto-detection `_is_local_url` auto-disabled subdomain replacement for loopback URLs. Replace with an explicit opt-out: `replace_subdomain` now defaults to `True`; consumers set `CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false` when pointing at a local mock server. - Remove `_is_local_url` from `_http.py` - Default `replace_subdomain` to `True` in `DefaultClient.__init__` - Update `config.py` and `user-guide.md` to document the env-var escape hatch - Remove `TestDefaultClientLocalMode` and related tests --- src/sap_cloud_sdk/cbc/_http.py | 22 ---------------------- src/sap_cloud_sdk/cbc/client.py | 10 +++------- src/sap_cloud_sdk/cbc/config.py | 12 +++++------- src/sap_cloud_sdk/cbc/user-guide.md | 12 ++++-------- tests/cbc/unit/test_client.py | 25 ------------------------- 5 files changed, 12 insertions(+), 69 deletions(-) diff --git a/src/sap_cloud_sdk/cbc/_http.py b/src/sap_cloud_sdk/cbc/_http.py index 5c5ecafd..d63725c8 100644 --- a/src/sap_cloud_sdk/cbc/_http.py +++ b/src/sap_cloud_sdk/cbc/_http.py @@ -1,7 +1,6 @@ """Low-level HTTP transport for the CBC (Central Business Configuration) module. Provides: -- :func:`_is_local_url` — detects loopback URLs that skip mTLS and subdomain routing. - :class:`_LazyCertTransport` — httpx transport that defers mTLS cert loading until the first real connection, so clients can be constructed with cert data that has not yet been written to disk. @@ -16,27 +15,6 @@ import httpx -def _is_local_url(url: str) -> bool: - """Return ``True`` when *url* targets a loopback address. - - Loopback addresses (``http://localhost``, ``http://127.0.0.1``, - ``http://[::1]``) bypass mTLS and subdomain-per-tenant routing — they - point directly at a mock or local dev server. - - Args: - url: Base URL to test. - - Returns: - ``True`` if the URL targets a loopback address, ``False`` otherwise. - """ - lower = url.lower() - return ( - lower.startswith("http://localhost") - or lower.startswith("http://127.0.0.1") - or lower.startswith("http://[::1]") - ) - - class _LazyCertTransport(httpx.BaseTransport): """httpx transport that defers ``ssl.SSLContext.load_cert_chain`` until first use. diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index 08bc3981..97da6c5d 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -5,7 +5,7 @@ - :class:`CBCClient` — Protocol defining the client interface; use for type annotations and test doubles. - :class:`DefaultClient` — Production client. Handles both production (mTLS + - envoy subdomain routing) and local/mock mode (detected automatically from the URL). + envoy subdomain routing) and mock-server mode (set ``CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false``). - :func:`create_client` — Factory that resolves the right client from environment variables via :func:`~sap_cloud_sdk.cbc.config.load_from_env`. @@ -34,7 +34,7 @@ if TYPE_CHECKING: from sap_cloud_sdk.cbc.config import CBCConfig -from sap_cloud_sdk.cbc._http import _LazyCertTransport, _is_local_url +from sap_cloud_sdk.cbc._http import _LazyCertTransport from sap_cloud_sdk.cbc._models import ( ApiError, ConfigData, @@ -153,11 +153,7 @@ def __init__( replace_subdomain: bool | None = None, ) -> None: self._base_url = base_url.rstrip("/") - resolved_replace = ( - replace_subdomain - if replace_subdomain is not None - else not _is_local_url(base_url) - ) + resolved_replace = replace_subdomain if replace_subdomain is not None else True self._config = _ClientConfig( configurations_path="/configuration/v1", replace_subdomain=resolved_replace, diff --git a/src/sap_cloud_sdk/cbc/config.py b/src/sap_cloud_sdk/cbc/config.py index 7f151d99..81cbb9c0 100644 --- a/src/sap_cloud_sdk/cbc/config.py +++ b/src/sap_cloud_sdk/cbc/config.py @@ -33,13 +33,13 @@ class CBCConfig: Attributes: base_url: CBC service base URL. - cert_path: Path to the PEM client certificate file, or ``None`` for local/mock mode. - key_path: Path to the PEM private key file, or ``None`` for local/mock mode. + cert_path: Path to the PEM client certificate file, or ``None`` when not using mTLS. + key_path: Path to the PEM private key file, or ``None`` when not using mTLS. cert_pem: PEM client certificate value. Alternative to ``cert_path``. key_pem: PEM private key value. Alternative to ``key_path``. replace_subdomain: Whether to rewrite the URL subdomain to the CBC tenant ID - on each request. ``None`` (default) auto-detects: loopback URLs disable it, - all others enable it. Set explicitly to ``False`` for HTTPS mock servers. + on each request. Defaults to ``True``. Set to ``False`` when pointing + at a local mock server (e.g. via ``CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false``). """ base_url: str @@ -61,9 +61,7 @@ def load_from_env() -> CBCConfig: 2. **Value triplet** — ``CLOUD_SDK_CBC_CERT``, ``CLOUD_SDK_CBC_KEY``, and ``CLOUD_SDK_CBC_URL`` must all be set. PEM values are written to temp files deleted after the first connection. - 3. **URL only** — loopback addresses (``http://localhost``, - ``http://127.0.0.1``) trigger local/mock mode (no mTLS, no subdomain - replacement). Non-loopback URLs produce a client without mTLS. + 3. **URL only** — ``CLOUD_SDK_CBC_URL`` is set without credentials. No mTLS. Returns: A :class:`CBCConfig` ready for use by :func:`~sap_cloud_sdk.cbc.create_client`. diff --git a/src/sap_cloud_sdk/cbc/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index 5e954098..8280ac98 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -26,12 +26,10 @@ from sap_cloud_sdk.cbc import create_client, TenantContext client = create_client() # reads CLOUD_SDK_CBC_URL, CLOUD_SDK_CBC_CERT_PATH, CLOUD_SDK_CBC_KEY_PATH ``` -For local development against a mock server — no credentials needed: +For local development against a mock server, set `CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false` to disable subdomain rewriting: -```python -from sap_cloud_sdk.cbc import DefaultClient - -client = DefaultClient(base_url="http://localhost:8001") +```bash +CLOUD_SDK_CBC_URL=http://localhost:8001 CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false python my_agent.py ``` ## Reading configuration @@ -138,9 +136,7 @@ except CBCNetworkError: | `CLOUD_SDK_CBC_KEY_PATH` | prod only | Path to the mTLS private key (PEM file) | | `CLOUD_SDK_CBC_CERT` | prod only | mTLS client certificate value (PEM string, alternative to `CERT_PATH`) | | `CLOUD_SDK_CBC_KEY` | prod only | mTLS private key value (PEM string, alternative to `KEY_PATH`) | -| `CLOUD_SDK_CBC_REPLACE_SUBDOMAIN` | no | Override subdomain replacement (`true`/`false`). Auto-detected from URL when unset. | - -Local mode (loopback URL) requires only `CLOUD_SDK_CBC_URL`. +| `CLOUD_SDK_CBC_REPLACE_SUBDOMAIN` | no | Override subdomain replacement (`true`/`false`). Defaults to `true`. Set to `false` when pointing at a local mock server. | `CERT_PATH`/`KEY_PATH` (file paths) take precedence over `CERT`/`KEY` (values) when both are set. diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index 2bf1f616..966abbee 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -55,25 +55,6 @@ def _make_client( return client, mock_http -# --------------------------------------------------------------------------- -# DefaultClient — URL detection -# --------------------------------------------------------------------------- - - -class TestDefaultClientLocalMode: - def test_loopback_localhost_disables_subdomain_replacement(self): - client, _ = _make_client("http://localhost:8001") - assert not client._config.replace_subdomain - - def test_loopback_127_disables_subdomain_replacement(self): - client, _ = _make_client("http://127.0.0.1:8001") - assert not client._config.replace_subdomain - - def test_production_url_enables_subdomain_replacement(self): - client, _ = _make_client("https://cbc.example.ondemand.com") - assert client._config.replace_subdomain - - # --------------------------------------------------------------------------- # DefaultClient — URL building # --------------------------------------------------------------------------- @@ -85,12 +66,6 @@ def test_production_replaces_subdomain_with_tenant(self): url = client._configurations_url(_tenant("my-tenant"), "/consumptionVersions") assert url.startswith("https://my-tenant.") - def test_local_does_not_replace_subdomain(self): - client, _ = _make_client("http://localhost:8001") - url = client._configurations_url(_tenant("my-tenant"), "/consumptionVersions") - assert "localhost:8001" in url - assert "my-tenant" not in url.split("//")[1].split("/")[0] - # --------------------------------------------------------------------------- # DefaultClient — get_consumption_versions From ff80ceb92a433ebe04fc017498aa0ca562ec6ab3 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Fri, 18 Sep 2026 23:14:32 +0530 Subject: [PATCH 06/14] fix(cbc): bump telemetry module count to 17 after upstream addition --- tests/core/unit/telemetry/test_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core/unit/telemetry/test_module.py b/tests/core/unit/telemetry/test_module.py index 73174a67..77cb965a 100644 --- a/tests/core/unit/telemetry/test_module.py +++ b/tests/core/unit/telemetry/test_module.py @@ -58,7 +58,7 @@ 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 From 33f6e35415f4e3c5e6b102cb8739b1bee0293bb4 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Fri, 18 Sep 2026 23:30:22 +0530 Subject: [PATCH 07/14] feat(cbc): move tenant_context to client level Binds `TenantContext | Callable[[], TenantContext]` at construction time instead of per-call. The callable form supports multi-tenant agents where the tenant varies per request (e.g. read from a request-scoped context var). - `DefaultClient.__init__` and `create_client` gain `tenant_context` param - `get_consumption_versions` and `get_configuration` drop the param - `CBCClient` Protocol updated to match - Integration conftest bakes tenant into the client fixture - Tests cover callable invocation count and missing-tenant error --- src/sap_cloud_sdk/cbc/client.py | 81 ++++++++++++++++++--------- src/sap_cloud_sdk/cbc/user-guide.md | 18 +++--- tests/cbc/integration/conftest.py | 11 +++- tests/cbc/integration/test_e2e_bdd.py | 12 ++-- tests/cbc/unit/test_client.py | 58 ++++++++++++++++--- 5 files changed, 128 insertions(+), 52 deletions(-) diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index 97da6c5d..727f9d13 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -13,10 +13,15 @@ from sap_cloud_sdk.cbc import create_client, TenantContext - client = create_client() - config = client.get_configuration( - TenantContext(cbcTenantId="my-cbc-tenant", appTenantId="my-app-tenant") + # single-tenant: bind at construction time + client = create_client( + tenant_context=TenantContext(cbcTenantId="my-cbc-tenant", appTenantId="my-app-tenant") ) + config = client.get_configuration() + + # multi-tenant: callable reads from request-scoped context at call time + client = create_client(tenant_context=lambda: resolve_tenant()) + config = client.get_configuration() """ from __future__ import annotations @@ -25,6 +30,7 @@ import re import ssl import tempfile +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol @@ -69,10 +75,8 @@ class CBCClient(Protocol): double, offline stub, or alternative production client. """ - def get_consumption_versions( - self, tenant_context: TenantContext - ) -> ConsumptionVersions: - """Return the available consumption versions for the given tenant. + 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 @@ -82,7 +86,6 @@ def get_consumption_versions( def get_configuration( self, - tenant_context: TenantContext, consumption_version: str | None = None, ) -> ConfigData: """Return the business configuration for all entities in one call. @@ -118,19 +121,30 @@ class DefaultClient: credentials automatically (BTP Destination Service or environment variables, or accepts an explicit :class:`~sap_cloud_sdk.cbc.config.CBCConfig`):: - client = create_client() + client = create_client( + tenant_context=TenantContext(cbcTenantId="my-cbc-tenant", appTenantId="my-app-tenant") + ) + + # multi-tenant: callable is invoked on every request + client = create_client(tenant_context=lambda: resolve_tenant()) # explicit config - client = create_client(config=CBCConfig( - base_url="https://service.app.prod-eu.cbc.services.cloud.sap", - cert_path=Path("/run/secrets/tls.crt"), - key_path=Path("/run/secrets/tls.key"), - )) + client = create_client( + config=CBCConfig( + base_url="https://service.app.prod-eu.cbc.services.cloud.sap", + cert_path=Path("/run/secrets/tls.crt"), + key_path=Path("/run/secrets/tls.key"), + ), + tenant_context=TenantContext(...), + ) Direct instantiation is supported for testing (inject a mock ``http_client``). Args: base_url: Base URL of the CBC service. + tenant_context: Tenant identification, or a callable that returns it. + The callable form is for multi-tenant agents where the tenant varies + per request (e.g. read from a request-scoped context variable). http_client: Optional pre-configured ``httpx.Client`` — takes full precedence over all mTLS arguments. Use for testing. ssl_context: Optional pre-built :class:`ssl.SSLContext` with mTLS loaded. @@ -144,6 +158,7 @@ class DefaultClient: def __init__( self, base_url: str, + tenant_context: TenantContext | Callable[[], TenantContext] | None = None, http_client: httpx.Client | None = None, ssl_context: ssl.SSLContext | None = None, cert_path: Path | None = None, @@ -153,6 +168,7 @@ def __init__( replace_subdomain: bool | None = None, ) -> None: self._base_url = base_url.rstrip("/") + self._tenant_context = tenant_context resolved_replace = replace_subdomain if replace_subdomain is not None else True self._config = _ClientConfig( configurations_path="/configuration/v1", @@ -187,18 +203,23 @@ def __enter__(self) -> "DefaultClient": def __exit__(self, *args: Any) -> None: self.close() + def _resolve_tenant(self) -> TenantContext: + if self._tenant_context is None: + raise ValueError( + "No tenant_context configured. Pass tenant_context to create_client() " + "or DefaultClient()." + ) + if callable(self._tenant_context): + return self._tenant_context() + return self._tenant_context + # ------------------------------------------------------------------ # Public API methods # ------------------------------------------------------------------ @record_metrics(Module.CBC, Operation.CBC_GET_CONSUMPTION_VERSIONS) - def get_consumption_versions( - self, tenant_context: TenantContext - ) -> ConsumptionVersions: - """Return available consumption versions for the given tenant. - - Args: - tenant_context: Tenant identification. + def get_consumption_versions(self) -> ConsumptionVersions: + """Return available consumption versions for the configured tenant. Returns: :class:`ConsumptionVersions` with all versions for the tenant. @@ -208,6 +229,7 @@ def get_consumption_versions( CBCServerError: On 5xx responses. CBCNetworkError: On connection failures. """ + tenant_context = self._resolve_tenant() url = self._configurations_url( tenant_context, f"/consumptionVersions?appTenantId={tenant_context.app_tenant_id}", @@ -266,17 +288,15 @@ def _get_entity_data( @record_metrics(Module.CBC, Operation.CBC_GET_CONFIGURATION) def get_configuration( self, - tenant_context: TenantContext, consumption_version: str | None = None, ) -> ConfigData: - """Return the full business configuration for the given tenant. + """Return the full business configuration for the configured tenant. Fetches all entities and their data for the specified consumption version. When ``consumption_version`` is omitted, the latest version is resolved automatically via :meth:`get_consumption_versions`. Args: - tenant_context: Tenant identification. consumption_version: Consumption version ID. When ``None``, the latest version is resolved via :meth:`get_consumption_versions`. @@ -289,8 +309,9 @@ def get_configuration( CBCServerError: On 5xx responses. CBCNetworkError: On connection failures. """ + tenant_context = self._resolve_tenant() if consumption_version is None: - versions = self.get_consumption_versions(tenant_context) + versions = self.get_consumption_versions() latest = versions.latest() if latest is None: raise CBCClientError( @@ -409,7 +430,11 @@ def _request( # --------------------------------------------------------------------------- -def create_client(*, config: CBCConfig | None = None) -> CBCClient: +def create_client( + *, + config: CBCConfig | None = None, + tenant_context: TenantContext | Callable[[], TenantContext] | None = None, +) -> CBCClient: """Create a :class:`DefaultClient` from environment variables or an explicit config. When ``config`` is omitted, credentials are resolved via @@ -419,6 +444,9 @@ def create_client(*, config: CBCConfig | None = None) -> CBCClient: Args: config: Optional explicit :class:`~sap_cloud_sdk.cbc.config.CBCConfig`. When provided, env resolution is skipped entirely. + tenant_context: Tenant identification, or a callable that returns it. + The callable form is for multi-tenant agents where the tenant varies + per request. Returns: A configured :class:`DefaultClient`. @@ -432,6 +460,7 @@ def create_client(*, config: CBCConfig | None = None) -> CBCClient: resolved: CBCConfig = config if config is not None else load_from_env() return DefaultClient( base_url=resolved.base_url, + tenant_context=tenant_context, cert_path=resolved.cert_path, key_path=resolved.key_path, cert_pem=resolved.cert_pem, diff --git a/src/sap_cloud_sdk/cbc/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index 8280ac98..9aef0eeb 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -23,7 +23,13 @@ object has one or more related entities; each entity has a stable authored `id` ```python from sap_cloud_sdk.cbc import create_client, TenantContext -client = create_client() # reads CLOUD_SDK_CBC_URL, CLOUD_SDK_CBC_CERT_PATH, CLOUD_SDK_CBC_KEY_PATH +# single-tenant: bind tenant IDs at startup +client = create_client( + tenant_context=TenantContext(cbcTenantId="", appTenantId="") +) + +# multi-tenant: callable is invoked on every request +client = create_client(tenant_context=lambda: resolve_tenant_from_request_context()) ``` For local development against a mock server, set `CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false` to disable subdomain rewriting: @@ -37,18 +43,16 @@ CLOUD_SDK_CBC_URL=http://localhost:8001 CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false py ### Fetch everything in one call ```python -tenant = TenantContext(cbcTenantId="", appTenantId="") - # latest version resolved automatically -config = client.get_configuration(tenant) +config = client.get_configuration() # pin a specific version -config = client.get_configuration(tenant, consumption_version="a0392d4f-72a9-...") +config = client.get_configuration(consumption_version="a0392d4f-72a9-...") # pick from the list -versions = client.get_consumption_versions(tenant) +versions = client.get_consumption_versions() cv = versions.latest() # or versions.items[0], or your own selection logic -config = client.get_configuration(tenant, consumption_version=cv.version) +config = client.get_configuration(consumption_version=cv.version) ``` ### ConfigData structure diff --git a/tests/cbc/integration/conftest.py b/tests/cbc/integration/conftest.py index 005c299c..0d560474 100644 --- a/tests/cbc/integration/conftest.py +++ b/tests/cbc/integration/conftest.py @@ -25,8 +25,7 @@ ENV_APP_TENANT_ID = "CLOUD_SDK_CBC_APP_TENANT_ID" -@pytest.fixture(scope="session") -def cbc_tenant() -> TenantContext: +def _require_tenant() -> TenantContext: cbc_tid = os.environ.get(ENV_CBC_TENANT_ID) app_tid = os.environ.get(ENV_APP_TENANT_ID) if not cbc_tid or not app_tid: @@ -36,9 +35,15 @@ def cbc_tenant() -> TenantContext: return TenantContext(cbcTenantId=cbc_tid, appTenantId=app_tid) +@pytest.fixture(scope="session") +def cbc_tenant() -> TenantContext: + return _require_tenant() + + @pytest.fixture(scope="session") def cbc_client() -> CBCClient: + tenant = _require_tenant() try: - return create_client() + return create_client(tenant_context=tenant) except CBCConfigError as exc: pytest.skip(f"CBC integration tests skipped — missing config: {exc}") diff --git a/tests/cbc/integration/test_e2e_bdd.py b/tests/cbc/integration/test_e2e_bdd.py index 1c03f522..c63d798e 100644 --- a/tests/cbc/integration/test_e2e_bdd.py +++ b/tests/cbc/integration/test_e2e_bdd.py @@ -75,17 +75,13 @@ def cbc_context(cbc_client: DefaultClient, cbc_tenant: TenantContext): @when("I call get_consumption_versions") -def call_get_consumption_versions( - ctx: dict, cbc_client: DefaultClient, cbc_tenant: TenantContext -): - ctx["versions"] = cbc_client.get_consumption_versions(cbc_tenant) +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, cbc_tenant: TenantContext -): - ctx["config"] = cbc_client.get_configuration(cbc_tenant) +def call_get_configuration(ctx: dict, cbc_client: DefaultClient): + ctx["config"] = cbc_client.get_configuration() @then("the result should contain at least one version") diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index 966abbee..fef936ab 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -49,12 +49,54 @@ def _mock_response( def _make_client( base_url: str = "https://cbc.example.ondemand.com", + tenant: TenantContext | None = None, ) -> tuple[DefaultClient, MagicMock]: mock_http = MagicMock(spec=httpx.Client) - client = DefaultClient(base_url=base_url, http_client=mock_http) + client = DefaultClient( + base_url=base_url, + tenant_context=tenant or _tenant(), + http_client=mock_http, + ) return client, mock_http +# --------------------------------------------------------------------------- +# DefaultClient — tenant_context +# --------------------------------------------------------------------------- + + +class TestTenantContext: + def test_callable_is_invoked_on_each_call(self): + call_count = 0 + + def tenant_fn() -> TenantContext: + nonlocal call_count + call_count += 1 + return _tenant() + + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.return_value = _mock_response( + json_body={"items": [{"version": "cv1"}]} + ) + client = DefaultClient( + base_url="https://cbc.example.ondemand.com", + tenant_context=tenant_fn, + http_client=mock_http, + ) + client.get_consumption_versions() + client.get_consumption_versions() + assert call_count == 2 + + def test_raises_when_no_tenant_context(self): + mock_http = MagicMock(spec=httpx.Client) + client = DefaultClient( + base_url="https://cbc.example.ondemand.com", + http_client=mock_http, + ) + with pytest.raises(ValueError, match="tenant_context"): + client.get_consumption_versions() + + # --------------------------------------------------------------------------- # DefaultClient — URL building # --------------------------------------------------------------------------- @@ -78,7 +120,7 @@ def test_returns_parsed_versions(self): mock_http.request.return_value = _mock_response( json_body={"items": [{"version": "cv1"}]} ) - result = client.get_consumption_versions(_tenant()) + result = client.get_consumption_versions() assert len(result.items) == 1 assert result.items[0].version == "cv1" @@ -89,19 +131,19 @@ def test_raises_client_error_on_404(self): content=b'{"error":{"code":"NOT_FOUND","message":"not found"}}', ) with pytest.raises(CBCClientError): - client.get_consumption_versions(_tenant()) + 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(_tenant()) + 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(_tenant()) + client.get_consumption_versions() # --------------------------------------------------------------------------- @@ -180,7 +222,7 @@ def test_resolves_latest_version_when_none_given(self): entities_response = _mock_response(json_body={"items": []}) mock_http.request.side_effect = [versions_response, entities_response] - result = client.get_configuration(_tenant()) + result = client.get_configuration() assert isinstance(result, ConfigData) assert result.consumption_version == "v2" assert result.config_objects == [] @@ -189,7 +231,7 @@ 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(_tenant()) + client.get_configuration() def test_uses_explicit_consumption_version(self): client, mock_http = _make_client() @@ -207,7 +249,7 @@ def test_uses_explicit_consumption_version(self): data_response = _mock_response(json_body={"items": [{"k": "v"}]}) mock_http.request.side_effect = [entities_response, data_response] - result = client.get_configuration(_tenant(), consumption_version="cv1") + 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 From f003b42da1d5e57a13d67ea215d7d5f6e1cda211 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sat, 3 Oct 2026 13:44:25 +0530 Subject: [PATCH 08/14] =?UTF-8?q?refactor(cbc):=20two-layer=20client=20con?= =?UTF-8?q?tract=20=E2=80=94=20thin=20core=20+=20platform=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the CBC client around a generic core and a platform adapter layered strictly on top of it. Core (client.py): base_url and app_tenant_id are per-request callables; the only credential input is an ssl.SSLContext. create_client is a thin factory. Drops TenantContext, config.py, _http.py, and all env/cert-file machinery. Platform adapter (client_adapter.py): ships the SAP application-platform provisioning defaults. The SDK owns two ContextVars (app_tenant_id_var, tenant_subdomain_var) that the app populates; create_agent_client resolves base_url from the tenant-mapping Destination Fragment (listing the subaccount and matching on appTenantId, pre-PR-#79 shape) and loads the provider mTLS cert from the Destination Service. Every default is overridable via args or CLOUD_SDK_CBC_* env vars. CBC unit coverage 98% (adapter 100%). --- src/sap_cloud_sdk/cbc/__init__.py | 39 +-- src/sap_cloud_sdk/cbc/_http.py | 65 ----- src/sap_cloud_sdk/cbc/_models.py | 74 +++--- src/sap_cloud_sdk/cbc/client.py | 311 ++++++++---------------- src/sap_cloud_sdk/cbc/client_adapter.py | 290 ++++++++++++++++++++++ src/sap_cloud_sdk/cbc/config.py | 139 ----------- src/sap_cloud_sdk/cbc/user-guide.md | 136 +++++++---- tests/cbc/integration/cbc.feature | 2 +- tests/cbc/integration/conftest.py | 58 +++-- tests/cbc/integration/test_e2e_bdd.py | 20 +- tests/cbc/unit/test_client.py | 217 ++++++++--------- tests/cbc/unit/test_client_adapter.py | 225 +++++++++++++++++ tests/cbc/unit/test_config.py | 106 -------- tests/cbc/unit/test_models.py | 37 ++- 14 files changed, 936 insertions(+), 783 deletions(-) delete mode 100644 src/sap_cloud_sdk/cbc/_http.py create mode 100644 src/sap_cloud_sdk/cbc/client_adapter.py delete mode 100644 src/sap_cloud_sdk/cbc/config.py create mode 100644 tests/cbc/unit/test_client_adapter.py delete mode 100644 tests/cbc/unit/test_config.py diff --git a/src/sap_cloud_sdk/cbc/__init__.py b/src/sap_cloud_sdk/cbc/__init__.py index e03cbfd0..28165d6f 100644 --- a/src/sap_cloud_sdk/cbc/__init__.py +++ b/src/sap_cloud_sdk/cbc/__init__.py @@ -8,12 +8,14 @@ Quick start:: - from sap_cloud_sdk.cbc import create_client, TenantContext + from sap_cloud_sdk import cbc - client = create_client() - config = client.get_configuration( - TenantContext(cbcTenantId="my-cbc-tenant", appTenantId="my-app-tenant") + cbc_client = cbc.create_client( + base_url=lambda: resolve_cbc_url(), + app_tenant_id=lambda: resolve_app_tenant_id(), + ssl_context=ssl_ctx, ) + config = cbc_client.get_configuration() # Access entity data payment = config.get_config_object("payment-config") @@ -21,14 +23,10 @@ for row in payment.get_entity("payment-mode").data.as_list(): print(row) -Local / mock server — no credentials needed:: - - from sap_cloud_sdk.cbc import DefaultClient, TenantContext - - client = DefaultClient(base_url="http://localhost:8001") - config = client.get_configuration( - TenantContext(cbcTenantId="t1", appTenantId="app-t1") - ) +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 @@ -38,7 +36,12 @@ DefaultClient, create_client, ) -from sap_cloud_sdk.cbc.config import CBCConfig +from sap_cloud_sdk.cbc.client_adapter import ( + CBC_FRAGMENT_PREFIX, + app_tenant_id_var, + create_agent_client, + tenant_subdomain_var, +) from sap_cloud_sdk.cbc.exceptions import ( CBCError, CBCClientError, @@ -57,18 +60,20 @@ EntityContent, EntityData, NNV, - TenantContext, ) __all__ = [ # factories "create_client", + "create_agent_client", # clients "CBCClient", "DefaultClient", - # config - "CBCConfig", + # platform adapter + "app_tenant_id_var", + "tenant_subdomain_var", + "CBC_FRAGMENT_PREFIX", # exceptions "CBCError", "CBCClientError", @@ -77,8 +82,6 @@ "CBCNetworkError", "CBCServerError", "HttpContext", - # models — context - "TenantContext", # models — consumption versions "ConsumptionVersion", "ConsumptionVersions", diff --git a/src/sap_cloud_sdk/cbc/_http.py b/src/sap_cloud_sdk/cbc/_http.py deleted file mode 100644 index d63725c8..00000000 --- a/src/sap_cloud_sdk/cbc/_http.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Low-level HTTP transport for the CBC (Central Business Configuration) module. - -Provides: -- :class:`_LazyCertTransport` — httpx transport that defers mTLS cert loading - until the first real connection, so clients can be constructed with cert data - that has not yet been written to disk. -""" - -from __future__ import annotations - -import contextlib -import os -import ssl - -import httpx - - -class _LazyCertTransport(httpx.BaseTransport): - """httpx transport that defers ``ssl.SSLContext.load_cert_chain`` until first use. - - Cert files are not validated at construction time — the chain is loaded once, - lazily, before the first real HTTP connection. This allows :class:`DefaultClient` - to be instantiated with cert paths that are written after construction (e.g. in - tests), and avoids I/O at import time. - - Args: - cert_file: Path to the PEM-encoded client certificate file. - key_file: Path to the PEM-encoded private key file. - delete_after_load: When ``True``, both files are deleted from disk after - the cert chain is loaded. Use for temporary files written from - in-memory PEM strings. - """ - - def __init__( - self, cert_file: str, key_file: str, *, delete_after_load: bool = False - ) -> None: - self._cert_file = cert_file - self._key_file = key_file - self._delete_after_load = delete_after_load - self._inner: httpx.HTTPTransport | None = None - self._files_deleted = False - - def _ensure_inner(self) -> httpx.HTTPTransport: - if self._inner is None: - ctx = ssl.create_default_context() - ctx.load_cert_chain(certfile=self._cert_file, keyfile=self._key_file) - if self._delete_after_load: - os.unlink(self._cert_file) - os.unlink(self._key_file) - self._files_deleted = True - self._inner = httpx.HTTPTransport(verify=ctx) - return self._inner - - def handle_request(self, request: httpx.Request) -> httpx.Response: - return self._ensure_inner().handle_request(request) - - def close(self) -> None: - if self._delete_after_load and not self._files_deleted: - with contextlib.suppress(OSError): - os.unlink(self._cert_file) - with contextlib.suppress(OSError): - os.unlink(self._key_file) - self._files_deleted = True - if self._inner is not None: - self._inner.close() diff --git a/src/sap_cloud_sdk/cbc/_models.py b/src/sap_cloud_sdk/cbc/_models.py index 3fdff586..d099d95b 100644 --- a/src/sap_cloud_sdk/cbc/_models.py +++ b/src/sap_cloud_sdk/cbc/_models.py @@ -19,23 +19,6 @@ class _FrozenModel(BaseModel): model_config = ConfigDict(frozen=True, populate_by_name=True) -# --------------------------------------------------------------------------- -# Core context models -# --------------------------------------------------------------------------- - - -class TenantContext(_FrozenModel): - """Tenant identification required for all CBC API calls. - - Attributes: - cbc_tenant_id: CBC tenant identifier (subdomain used in URL routing). - app_tenant_id: Application-level tenant identifier. - """ - - cbc_tenant_id: str = Field(alias="cbcTenantId", min_length=1) - app_tenant_id: str = Field(alias="appTenantId", min_length=1) - - # --------------------------------------------------------------------------- # Consumption version models # --------------------------------------------------------------------------- @@ -102,34 +85,37 @@ def latest(self) -> ConsumptionVersion | None: # --------------------------------------------------------------------------- -class Entity(_FrozenModel): - """Metadata describing one entity within a consumption version. +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") - A config object groups one or several related entities, each holding a - different slice of the configuration. Use ``config_object_id`` and - ``entity_id`` together to locate the entity you need. + +class ConfigObjectEntry(_FrozenModel): + """One config object and its entities as returned by the API. Attributes: - internal_id: CBC-internal opaque identifier (used in API path calls). - entity_id: Authored entity key (e.g. ``"payment-mode"``). - config_object_id: Configuration object this entity belongs to. + config_object_id: Authored config object identifier (e.g. ``"payment-config"``). + entities: Entities belonging to this config object. """ - # CBC API: "entityId" is the internal GUID used in URL paths; - # "entityName" is the authored key (e.g. "payment-mode"). - internal_id: str = Field(alias="entityId") - entity_id: str | None = Field(default=None, alias="entityName") - config_object_id: str | None = Field(default=None, alias="configurationObjectId") + config_object_id: str = Field(alias="configurationObjectId") + entities: list[ConfigObjectEntity] -class Entities(_FrozenModel): - """Collection of business configuration entities. +class ConfigObjectList(_FrozenModel): + """Config objects for a consumption version, already grouped by the API. Attributes: - items: List of :class:`Entity` objects. + items: List of :class:`ConfigObjectEntry` objects. """ - items: list[Entity] + items: list[ConfigObjectEntry] class EntityContent: @@ -141,6 +127,22 @@ class EntityContent: 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. @@ -212,12 +214,12 @@ class ConfigData: Attributes: consumption_version: Version this data was fetched from. - tenant_context: Tenant this data belongs to. + app_tenant_id: Application tenant this data belongs to. config_objects: Configuration objects and their entity data. """ consumption_version: str - tenant_context: TenantContext + app_tenant_id: str config_objects: list[ConfigObject] def get_config_object(self, config_object_id: str) -> ConfigObject | None: diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index 727f9d13..91490728 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -4,53 +4,44 @@ - :class:`CBCClient` — Protocol defining the client interface; use for type annotations and test doubles. -- :class:`DefaultClient` — Production client. Handles both production (mTLS + - envoy subdomain routing) and mock-server mode (set ``CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false``). -- :func:`create_client` — Factory that resolves the right client from environment - variables via :func:`~sap_cloud_sdk.cbc.config.load_from_env`. +- :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.cbc import create_client, TenantContext + from sap_cloud_sdk import cbc - # single-tenant: bind at construction time - client = create_client( - tenant_context=TenantContext(cbcTenantId="my-cbc-tenant", appTenantId="my-app-tenant") + cbc_client = cbc.create_client( + base_url=lambda: resolve_cbc_url(), + app_tenant_id=lambda: resolve_app_tenant_id(), + ssl_context=ssl_ctx, ) - config = client.get_configuration() - - # multi-tenant: callable reads from request-scoped context at call time - client = create_client(tenant_context=lambda: resolve_tenant()) - config = client.get_configuration() + config = cbc_client.get_configuration() """ from __future__ import annotations import logging -import re import ssl -import tempfile from collections.abc import Callable from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, Any, Protocol +from typing import Any, Protocol import httpx -if TYPE_CHECKING: - from sap_cloud_sdk.cbc.config import CBCConfig - -from sap_cloud_sdk.cbc._http import _LazyCertTransport from sap_cloud_sdk.cbc._models import ( ApiError, ConfigData, ConfigObject, + ConfigObjectList, ConsumptionVersions, - Entities, - Entity, EntityContent, EntityData, - TenantContext, ) from sap_cloud_sdk.cbc.exceptions import ( CBCClientError, @@ -103,10 +94,9 @@ def get_configuration( @dataclass(frozen=True) class _ClientConfig: - """API path and routing configuration for a :class:`DefaultClient` instance.""" + """API path configuration for a :class:`DefaultClient` instance.""" configurations_path: str - replace_subdomain: bool # --------------------------------------------------------------------------- @@ -117,80 +107,40 @@ class _ClientConfig: class DefaultClient: """CBC client implementation. - Prefer :func:`create_client` over direct instantiation — it resolves - credentials automatically (BTP Destination Service or environment variables, - or accepts an explicit :class:`~sap_cloud_sdk.cbc.config.CBCConfig`):: + Prefer :func:`create_client` over direct instantiation:: - client = create_client( - tenant_context=TenantContext(cbcTenantId="my-cbc-tenant", appTenantId="my-app-tenant") - ) - - # multi-tenant: callable is invoked on every request - client = create_client(tenant_context=lambda: resolve_tenant()) - - # explicit config - client = create_client( - config=CBCConfig( - base_url="https://service.app.prod-eu.cbc.services.cloud.sap", - cert_path=Path("/run/secrets/tls.crt"), - key_path=Path("/run/secrets/tls.key"), - ), - tenant_context=TenantContext(...), + cbc_client = cbc.create_client( + base_url=lambda: resolve_cbc_url(), + app_tenant_id=lambda: resolve_app_tenant_id(), + ssl_context=ssl_ctx, ) Direct instantiation is supported for testing (inject a mock ``http_client``). Args: - base_url: Base URL of the CBC service. - tenant_context: Tenant identification, or a callable that returns it. - The callable form is for multi-tenant agents where the tenant varies - per request (e.g. read from a request-scoped context variable). + 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 all mTLS arguments. Use for testing. + precedence over ``ssl_context``. Use for testing. ssl_context: Optional pre-built :class:`ssl.SSLContext` with mTLS loaded. - cert_path: Path to the PEM client certificate file. Requires ``key_path``. - key_path: Path to the PEM private key file. Requires ``cert_path``. - cert_pem: Raw PEM string for the client certificate. Requires ``key_pem``. - Written to a temp file deleted after the first connection. - key_pem: Raw PEM string for the private key. Requires ``cert_pem``. """ def __init__( self, - base_url: str, - tenant_context: TenantContext | Callable[[], TenantContext] | None = None, + base_url: Callable[[], str], + app_tenant_id: Callable[[], str], http_client: httpx.Client | None = None, ssl_context: ssl.SSLContext | None = None, - cert_path: Path | None = None, - key_path: Path | None = None, - cert_pem: str | None = None, - key_pem: str | None = None, - replace_subdomain: bool | None = None, ) -> None: - self._base_url = base_url.rstrip("/") - self._tenant_context = tenant_context - resolved_replace = replace_subdomain if replace_subdomain is not None else True + self._base_url = base_url + self._app_tenant_id = app_tenant_id self._config = _ClientConfig( configurations_path="/configuration/v1", - replace_subdomain=resolved_replace, ) - - if http_client is None and ssl_context is None: - if cert_path is not None and key_path is not None: - transport = _LazyCertTransport(str(cert_path), str(key_path)) - http_client = httpx.Client(transport=transport) - elif cert_pem is not None and key_pem is not None: - with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as cf: - cf.write(cert_pem.encode()) - cert_file = cf.name - with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as kf: - kf.write(key_pem.encode()) - key_file = kf.name - transport = _LazyCertTransport( - cert_file, key_file, delete_after_load=True - ) - http_client = httpx.Client(transport=transport) - self._client = http_client or httpx.Client(verify=ssl_context or True) def close(self) -> None: @@ -203,15 +153,8 @@ def __enter__(self) -> "DefaultClient": def __exit__(self, *args: Any) -> None: self.close() - def _resolve_tenant(self) -> TenantContext: - if self._tenant_context is None: - raise ValueError( - "No tenant_context configured. Pass tenant_context to create_client() " - "or DefaultClient()." - ) - if callable(self._tenant_context): - return self._tenant_context() - return self._tenant_context + def _resolve_app_tenant_id(self) -> str: + return self._app_tenant_id() # ------------------------------------------------------------------ # Public API methods @@ -229,24 +172,26 @@ def get_consumption_versions(self) -> ConsumptionVersions: CBCServerError: On 5xx responses. CBCNetworkError: On connection failures. """ - tenant_context = self._resolve_tenant() + app_tenant_id = self._resolve_app_tenant_id() url = self._configurations_url( - tenant_context, - f"/consumptionVersions?appTenantId={tenant_context.app_tenant_id}", + f"/consumptionVersions?appTenantId={app_tenant_id}", ) return ConsumptionVersions.model_validate(self._request("GET", url).json()) - def _get_entities( - self, tenant_context: TenantContext, consumption_version: str - ) -> Entities: - """Return the entities for the given tenant and consumption version. + def _get_configuration_objects( + self, 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: - tenant_context: Tenant identification. + app_tenant_id: Application tenant identifier. consumption_version: Consumption version ID. Returns: - :class:`Entities` containing entity metadata. + :class:`ConfigObjectList` containing config objects and their entities. Raises: CBCClientError: On 4xx responses. @@ -254,36 +199,10 @@ def _get_entities( CBCNetworkError: On connection failures. """ url = self._configurations_url( - tenant_context, - f"/consumptionVersions/{consumption_version}/entities" - f"?appTenantId={tenant_context.app_tenant_id}", - ) - return Entities.model_validate(self._request("GET", url).json()) - - def _get_entity_data( - self, - tenant_context: TenantContext, - consumption_version: str, - entity_id: str, - ) -> EntityData: - """Return configuration rows for the given entity. - - Args: - tenant_context: Tenant identification. - consumption_version: Consumption version ID. - entity_id: Entity identifier. - - Returns: - :class:`EntityData` with metadata and configuration rows. - - Raises: - CBCClientError: If the entity is not found, or on other 4xx responses. - CBCServerError: On 5xx responses. - CBCNetworkError: On connection failures. - """ - return self._fetch_entity_data( - tenant_context, consumption_version, Entity(entityId=entity_id) + 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( @@ -292,9 +211,10 @@ def get_configuration( ) -> ConfigData: """Return the full business configuration for the configured tenant. - Fetches all entities and their data for the specified consumption version. - When ``consumption_version`` is omitted, the latest version is resolved - automatically via :meth:`get_consumption_versions`. + 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 @@ -309,42 +229,35 @@ def get_configuration( CBCServerError: On 5xx responses. CBCNetworkError: On connection failures. """ - tenant_context = self._resolve_tenant() + app_tenant_id = self._resolve_app_tenant_id() if consumption_version is None: versions = self.get_consumption_versions() latest = versions.latest() if latest is None: raise CBCClientError( - f"CBC returned no consumption version for " - f"tenant={tenant_context.app_tenant_id!r}." + f"CBC returned no consumption version for tenant={app_tenant_id!r}." ) consumption_version = latest.version - entities = self._get_entities(tenant_context, consumption_version) - if not entities.items: - return ConfigData( - consumption_version=consumption_version, - tenant_context=tenant_context, - config_objects=[], - ) - - entity_data_list = [ - self._fetch_entity_data(tenant_context, consumption_version, entity) - for entity in entities.items - ] - - grouped: dict[str, list[EntityData]] = {} - for ed, entity in zip(entity_data_list, entities.items): - key = entity.config_object_id or "" - grouped.setdefault(key, []).append(ed) - + co_list = self._get_configuration_objects(app_tenant_id, consumption_version) config_objects = [ - ConfigObject(config_object_id=co_id, entities=eds) - for co_id, eds in grouped.items() + ConfigObject( + config_object_id=entry.config_object_id, + entities=[ + self._fetch_entity_data( + 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, - tenant_context=tenant_context, + app_tenant_id=app_tenant_id, config_objects=config_objects, ) @@ -354,37 +267,32 @@ def get_configuration( def _fetch_entity_data( self, - tenant_context: TenantContext, + app_tenant_id: str, consumption_version: str, - entity: Entity, + config_object_id: str, + entity_id: str, ) -> EntityData: url = self._configurations_url( - tenant_context, - f"/consumptionVersions/{consumption_version}/entities" - f"/{entity.internal_id}/data" - f"?appTenantId={tenant_context.app_tenant_id}", - ) - response_data = self._request("GET", url).json() - - api_meta = ( - response_data.get("metadata", {}) if isinstance(response_data, dict) else {} - ) - raw_data = ( - response_data["items"] - if isinstance(response_data, dict) and "items" in response_data - else response_data + f"/consumptionVersions/{consumption_version}/configurationObjects" + f"/{config_object_id}/entities/{entity_id}/data" + f"?appTenantId={app_tenant_id}", ) - entity_id = entity.entity_id or api_meta.get("entityName") or entity.internal_id - return EntityData(entity_id=entity_id, data=EntityContent(raw_data)) - - def _configurations_url(self, tenant_context: TenantContext, path: str = "") -> str: - base = self._base_url - if self._config.replace_subdomain: - base = re.sub( - r"^(https?://)[^.]+\.", - rf"\g<1>{tenant_context.cbc_tenant_id}.", - base, - ) + 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, path: str = "") -> str: + base = self._base_url().rstrip("/") return f"{base}{self._config.configurations_path}{path}" def _request( @@ -432,38 +340,23 @@ def _request( def create_client( *, - config: CBCConfig | None = None, - tenant_context: TenantContext | Callable[[], TenantContext] | None = None, + base_url: Callable[[], str], + app_tenant_id: Callable[[], str], + ssl_context: ssl.SSLContext | None = None, ) -> CBCClient: - """Create a :class:`DefaultClient` from environment variables or an explicit config. - - When ``config`` is omitted, credentials are resolved via - :func:`~sap_cloud_sdk.cbc.config.load_from_env` (reads - ``CLOUD_SDK_CBC_URL``, ``CLOUD_SDK_CBC_CERT_PATH``, ``CLOUD_SDK_CBC_KEY_PATH``). + """Create a :class:`DefaultClient`. Args: - config: Optional explicit :class:`~sap_cloud_sdk.cbc.config.CBCConfig`. - When provided, env resolution is skipped entirely. - tenant_context: Tenant identification, or a callable that returns it. - The callable form is for multi-tenant agents where the tenant varies - per request. + 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 pre-built :class:`ssl.SSLContext` with mTLS loaded. Returns: A configured :class:`DefaultClient`. - - Raises: - CBCConfigError: If no configuration is provided and none can be resolved - from the environment. """ - from sap_cloud_sdk.cbc.config import load_from_env - - resolved: CBCConfig = config if config is not None else load_from_env() return DefaultClient( - base_url=resolved.base_url, - tenant_context=tenant_context, - cert_path=resolved.cert_path, - key_path=resolved.key_path, - cert_pem=resolved.cert_pem, - key_pem=resolved.key_pem, - replace_subdomain=resolved.replace_subdomain, + 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..2af61e8e --- /dev/null +++ b/src/sap_cloud_sdk/cbc/client_adapter.py @@ -0,0 +1,290 @@ +"""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 loaded **once** when the client is built. Platform +certificates are typically short-lived, so recreate the client before the +certificate expires to pick up the rotated certificate. + +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.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 / ssl_context 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`. + + 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. + + 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 + + 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() + + client = create_fragment_client(instance=destination_instance) + fragments = client.list_subaccount_fragments(tenant=subdomain) + 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`. + + 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 is not found. + """ + from sap_cloud_sdk.destination import AccessStrategy, create_certificate_client + from sap_cloud_sdk.destination._cert_loader import _load_pem + + cert = create_certificate_client( + instance=destination_instance + ).get_subaccount_certificate( + cert_name, access_strategy=AccessStrategy.PROVIDER_ONLY + ) + 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( + *, + ssl_context: ssl.SSLContext | None = None, + destination_instance: str | None = None, + cbc_cert_name: str | None = None, + p12_password: bytes | 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 once from the + Destination Service. Because the certificate is loaded once, recreate the + client before the certificate expires so it picks up the rotated + certificate. + + Args: + ssl_context: Pre-built :class:`ssl.SSLContext`. When given, the + certificate load is skipped entirely (use for startup injection or + tests). + destination_instance: The ``instance`` passed to the destination + ``create_fragment_client`` / ``create_certificate_client`` (used for + secret resolution in cloud mode). Defaults to the + ``CLOUD_SDK_CBC_DESTINATION_INSTANCE`` env var, else ``"default"``. + cbc_cert_name: Name of the app's mTLS certificate. Defaults to the + ``CLOUD_SDK_CBC_CERTIFICATE_NAME`` env var, else derived from the + platform landscape (``APPFND_CONHOS_LANDSCAPE``). + p12_password: Certificate keystore password. Defaults to the + ``CLOUD_SDK_CBC_P12_PASSWORD`` env var, else ``None``. + + Returns: + A configured CBC client. + + Raises: + CBCConfigError: If the certificate cannot be resolved at construction + time (unless ``ssl_context`` is supplied), or — when a request is + made — if a ContextVar is empty or the tenant-mapping fragment is + missing. + """ + if destination_instance is None: + destination_instance = os.environ.get(ENV_DESTINATION_INSTANCE, "default") + + if ssl_context is None: + resolved_cert_name = ( + cbc_cert_name or os.environ.get(ENV_CERT_NAME) or _default_cert_name() + ) + resolved_password = ( + p12_password if p12_password is not None else _cert_password_from_env() + ) + ssl_context = _load_ssl_context( + destination_instance, resolved_cert_name, resolved_password + ) + + return create_client( + base_url=lambda: _resolve_base_url(destination_instance), + app_tenant_id=_resolve_app_tenant_id, + ssl_context=ssl_context, + ) diff --git a/src/sap_cloud_sdk/cbc/config.py b/src/sap_cloud_sdk/cbc/config.py deleted file mode 100644 index 81cbb9c0..00000000 --- a/src/sap_cloud_sdk/cbc/config.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Configuration and credential resolution for the CBC (Central Business Configuration) module. - -Reads mTLS credentials for the CBC service from environment variables. - -Environment variables:: - - CLOUD_SDK_CBC_URL CBC service base URL (required) - CLOUD_SDK_CBC_CERT_PATH Path to PEM client certificate file - CLOUD_SDK_CBC_KEY_PATH Path to PEM private key file - CLOUD_SDK_CBC_CERT PEM client certificate value (alternative to CERT_PATH) - CLOUD_SDK_CBC_KEY PEM private key value (alternative to KEY_PATH) -""" - -from __future__ import annotations - -import os -from dataclasses import dataclass -from pathlib import Path - -from sap_cloud_sdk.cbc.exceptions import CBCConfigError - -ENV_URL = "CLOUD_SDK_CBC_URL" -ENV_CERT_PATH = "CLOUD_SDK_CBC_CERT_PATH" -ENV_KEY_PATH = "CLOUD_SDK_CBC_KEY_PATH" -ENV_CERT = "CLOUD_SDK_CBC_CERT" -ENV_KEY = "CLOUD_SDK_CBC_KEY" -ENV_REPLACE_SUBDOMAIN = "CLOUD_SDK_CBC_REPLACE_SUBDOMAIN" - - -@dataclass(frozen=True) -class CBCConfig: - """Resolved configuration for the CBC service. - - Attributes: - base_url: CBC service base URL. - cert_path: Path to the PEM client certificate file, or ``None`` when not using mTLS. - key_path: Path to the PEM private key file, or ``None`` when not using mTLS. - cert_pem: PEM client certificate value. Alternative to ``cert_path``. - key_pem: PEM private key value. Alternative to ``key_path``. - replace_subdomain: Whether to rewrite the URL subdomain to the CBC tenant ID - on each request. Defaults to ``True``. Set to ``False`` when pointing - at a local mock server (e.g. via ``CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false``). - """ - - base_url: str - cert_path: Path | None = None - key_path: Path | None = None - cert_pem: str | None = None - key_pem: str | None = None - replace_subdomain: bool | None = None - - -def load_from_env() -> CBCConfig: - """Load CBC configuration from environment variables. - - Resolution order (first match wins): - - 1. **Path triplet** — ``CLOUD_SDK_CBC_CERT_PATH``, ``CLOUD_SDK_CBC_KEY_PATH``, - and ``CLOUD_SDK_CBC_URL`` must all be set. The path vars must point to - existing PEM files. - 2. **Value triplet** — ``CLOUD_SDK_CBC_CERT``, ``CLOUD_SDK_CBC_KEY``, and - ``CLOUD_SDK_CBC_URL`` must all be set. PEM values are written to temp - files deleted after the first connection. - 3. **URL only** — ``CLOUD_SDK_CBC_URL`` is set without credentials. No mTLS. - - Returns: - A :class:`CBCConfig` ready for use by :func:`~sap_cloud_sdk.cbc.create_client`. - - Raises: - CBCConfigError: If no configuration is found, or configuration is partially - set and unusable — e.g. only one of the cert/key env vars is set, or a - path env var points to a non-existent file. - """ - url = os.environ.get(ENV_URL) - replace_subdomain = _read_env_bool(ENV_REPLACE_SUBDOMAIN) - - cert_path = _read_env_path(ENV_CERT_PATH) - key_path = _read_env_path(ENV_KEY_PATH) - if cert_path and key_path and url: - return CBCConfig( - base_url=url, - cert_path=cert_path, - key_path=key_path, - replace_subdomain=replace_subdomain, - ) - if cert_path or key_path: - raise CBCConfigError( - "CBC env-var credential triplet is incomplete. " - f"Set all of {ENV_CERT_PATH}, {ENV_KEY_PATH}, and {ENV_URL} — or none." - ) - - cert_pem = os.environ.get(ENV_CERT) - key_pem = os.environ.get(ENV_KEY) - if cert_pem and key_pem and url: - return CBCConfig( - base_url=url, - cert_pem=cert_pem, - key_pem=key_pem, - replace_subdomain=replace_subdomain, - ) - if cert_pem or key_pem: - raise CBCConfigError( - "CBC env-var credential pair is incomplete. " - f"Set both {ENV_CERT} and {ENV_KEY} together with {ENV_URL} — or none." - ) - - if url: - return CBCConfig(base_url=url, replace_subdomain=replace_subdomain) - - raise CBCConfigError( - f"No CBC configuration found. Set {ENV_URL} at minimum, " - f"or provide mTLS credentials via {ENV_CERT_PATH} / {ENV_KEY_PATH} " - f"or {ENV_CERT} / {ENV_KEY}." - ) - - -def _read_env_bool(name: str) -> bool | None: - """Return True/False from env var ``name``, or ``None`` when unset.""" - raw = os.environ.get(name) - if not raw: - return None - return raw.strip().lower() in ("1", "true", "yes") - - -def _read_env_path(name: str) -> Path | None: - """Return the path named by env var ``name``, or ``None`` when unset. - - Raises: - CBCConfigError: If the env var is set but the path does not exist. - """ - raw = os.environ.get(name) - if not raw or not raw.strip(): - return None - p = Path(raw).expanduser() - if not p.exists(): - raise CBCConfigError( - f"Env var {name}={raw!r} points to a path that does not exist." - ) - return p diff --git a/src/sap_cloud_sdk/cbc/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index 9aef0eeb..f77f1f3c 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -20,23 +20,24 @@ object has one or more related entities; each entity has a stable authored `id` ## 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.cbc import create_client, TenantContext +from sap_cloud_sdk import cbc -# single-tenant: bind tenant IDs at startup -client = create_client( - tenant_context=TenantContext(cbcTenantId="", appTenantId="") +cbc_client = cbc.create_client( + base_url=lambda: resolve_cbc_url(), + app_tenant_id=lambda: resolve_app_tenant_id(), + ssl_context=ssl_ctx, ) - -# multi-tenant: callable is invoked on every request -client = create_client(tenant_context=lambda: resolve_tenant_from_request_context()) ``` -For local development against a mock server, set `CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false` to disable subdomain rewriting: - -```bash -CLOUD_SDK_CBC_URL=http://localhost:8001 CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false python my_agent.py -``` +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 @@ -44,15 +45,15 @@ CLOUD_SDK_CBC_URL=http://localhost:8001 CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false py ```python # latest version resolved automatically -config = client.get_configuration() +config = cbc_client.get_configuration() # pin a specific version -config = client.get_configuration(consumption_version="a0392d4f-72a9-...") +config = cbc_client.get_configuration(consumption_version="a0392d4f-72a9-...") # pick from the list -versions = client.get_consumption_versions() -cv = versions.latest() # or versions.items[0], or your own selection logic -config = client.get_configuration(consumption_version=cv.version) +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 @@ -60,7 +61,7 @@ config = client.get_configuration(consumption_version=cv.version) ``` ConfigData ├── consumption_version: str # e.g. "a0392d4f-72a9-..." -├── tenant_context: TenantContext +├── app_tenant_id: str └── config_objects: list[ConfigObject] ├── ConfigObject │ ├── config_object_id: str # e.g. "payment-config" @@ -88,9 +89,9 @@ for co in config.config_objects: ```python # All entities for one config object -payment = config.get_config_object("payment-config") # ConfigObject | None +payment = config.get_config_object("payment-config") # ConfigObject | None if payment: - modes = payment.get_entity("payment-mode") # EntityData | None + modes = payment.get_entity("payment-mode") # EntityData | None if modes: for row in modes.data.as_list(): print(row["paymentModeCode"], row["name"]) @@ -99,6 +100,17 @@ if payment: `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 @@ -118,49 +130,91 @@ policy = PolicyConfig(**policy_entity.data.as_object()) | `CBCClientError` | 4xx from CBC (e.g. tenant not found) | | `CBCServerError` | 5xx from CBC | | `CBCNetworkError` | connection failure | -| `CBCConfigError` | missing or incomplete credentials at startup | +| `CBCConfigError` | platform adapter cannot resolve the tenant mapping or certificate | ```python -from sap_cloud_sdk.cbc import CBCClientError, CBCServerError, CBCNetworkError +from sap_cloud_sdk import cbc try: - config = client.get_configuration(tenant) -except CBCClientError as e: - print(e.code, e.message) # e.g. "NOT_FOUND", "Tenant unknown" -except CBCNetworkError: + 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 ``` -## Environment variables +## 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). -| Variable | Required | Description | +```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 once at startup. + +**Certificate rotation.** The mTLS certificate is loaded once when the client is +built. Platform certificates are typically short-lived, so recreate the client — +call `create_agent_client()` again — before the certificate expires to pick up +the rotated certificate. + +### Platform env overrides + +Defaults cover the common case; override via env when needed: + +| Variable | Default | Description | |---|---|---| -| `CLOUD_SDK_CBC_URL` | yes | Base URL of the CBC service | -| `CLOUD_SDK_CBC_CERT_PATH` | prod only | Path to the mTLS client certificate (PEM file) | -| `CLOUD_SDK_CBC_KEY_PATH` | prod only | Path to the mTLS private key (PEM file) | -| `CLOUD_SDK_CBC_CERT` | prod only | mTLS client certificate value (PEM string, alternative to `CERT_PATH`) | -| `CLOUD_SDK_CBC_KEY` | prod only | mTLS private key value (PEM string, alternative to `KEY_PATH`) | -| `CLOUD_SDK_CBC_REPLACE_SUBDOMAIN` | no | Override subdomain replacement (`true`/`false`). Defaults to `true`. Set to `false` when pointing at a local mock server. | +| `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 | -`CERT_PATH`/`KEY_PATH` (file paths) take precedence over `CERT`/`KEY` (values) when both are set. +An explicit `ssl_context=` passed to `create_agent_client` short-circuits the +certificate load entirely. ## Using a test double `CBCClient` is a `Protocol` — implement it directly in tests: ```python -from sap_cloud_sdk.cbc import CBCClient, ConfigData, TenantContext +from sap_cloud_sdk import cbc + class StubCBCClient: - def get_consumption_versions(self, tenant_context): - ... - def get_configuration(self, tenant_context, consumption_version=None): - return ConfigData( + def get_consumption_versions(self): ... + def get_configuration(self, consumption_version=None): + return cbc.ConfigData( consumption_version="cv1", - tenant_context=tenant_context, + app_tenant_id="app-t1", config_objects=[], ) + def test_my_service(): service = MyService(cbc_client=StubCBCClient()) ... diff --git a/tests/cbc/integration/cbc.feature b/tests/cbc/integration/cbc.feature index 442c4815..f3da6f35 100644 --- a/tests/cbc/integration/cbc.feature +++ b/tests/cbc/integration/cbc.feature @@ -18,7 +18,7 @@ Feature: CBC (Central Business Configuration) Integration 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 tenant_context should match the configured tenant + And the app_tenant_id should match the configured tenant Scenario: Full configuration contains at least one config object When I call get_configuration diff --git a/tests/cbc/integration/conftest.py b/tests/cbc/integration/conftest.py index 0d560474..b7fbf033 100644 --- a/tests/cbc/integration/conftest.py +++ b/tests/cbc/integration/conftest.py @@ -3,47 +3,61 @@ 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_CBC_TENANT_ID CBC tenant ID for subdomain routing (required) CLOUD_SDK_CBC_APP_TENANT_ID Application tenant ID (required) - CLOUD_SDK_CBC_CERT_PATH Path to mTLS client certificate (optional) - CLOUD_SDK_CBC_KEY_PATH Path to mTLS private key (optional) - CLOUD_SDK_CBC_REPLACE_SUBDOMAIN Override subdomain replacement (optional) + 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. +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, TenantContext, create_client -from sap_cloud_sdk.cbc.exceptions import CBCConfigError +from sap_cloud_sdk.cbc import CBCClient, create_client -ENV_CBC_TENANT_ID = "CLOUD_SDK_CBC_CBC_TENANT_ID" +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_tenant() -> TenantContext: - cbc_tid = os.environ.get(ENV_CBC_TENANT_ID) +def _require_app_tenant_id() -> str: app_tid = os.environ.get(ENV_APP_TENANT_ID) - if not cbc_tid or not app_tid: - pytest.skip( - f"CBC integration tests skipped — set {ENV_CBC_TENANT_ID} and {ENV_APP_TENANT_ID}." - ) - return TenantContext(cbcTenantId=cbc_tid, appTenantId=app_tid) + 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_tenant() -> TenantContext: - return _require_tenant() +def cbc_app_tenant_id() -> str: + return _require_app_tenant_id() @pytest.fixture(scope="session") def cbc_client() -> CBCClient: - tenant = _require_tenant() - try: - return create_client(tenant_context=tenant) - except CBCConfigError as exc: - pytest.skip(f"CBC integration tests skipped — missing config: {exc}") + 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}.") + return create_client( + base_url=lambda: url, + app_tenant_id=lambda: app_tid, + ssl_context=_build_ssl_context(), + ) diff --git a/tests/cbc/integration/test_e2e_bdd.py b/tests/cbc/integration/test_e2e_bdd.py index c63d798e..a6fd4642 100644 --- a/tests/cbc/integration/test_e2e_bdd.py +++ b/tests/cbc/integration/test_e2e_bdd.py @@ -3,16 +3,6 @@ Run against a real or mock CBC server:: CLOUD_SDK_CBC_URL=http://localhost:8001 \\ - CLOUD_SDK_CBC_CBC_TENANT_ID=my-cbc-tenant \\ - CLOUD_SDK_CBC_APP_TENANT_ID=my-app-tenant \\ - pytest tests/cbc/integration - -Or against production (with mTLS):: - - CLOUD_SDK_CBC_URL=https://cbc.example.ondemand.com \\ - CLOUD_SDK_CBC_CERT_PATH=/run/secrets/tls.crt \\ - CLOUD_SDK_CBC_KEY_PATH=/run/secrets/tls.key \\ - CLOUD_SDK_CBC_CBC_TENANT_ID=my-cbc-tenant \\ CLOUD_SDK_CBC_APP_TENANT_ID=my-app-tenant \\ pytest tests/cbc/integration """ @@ -22,7 +12,7 @@ import pytest from pytest_bdd import given, scenario, then, when -from sap_cloud_sdk.cbc import ConfigData, ConsumptionVersions, TenantContext +from sap_cloud_sdk.cbc import ConfigData, ConsumptionVersions from sap_cloud_sdk.cbc.client import DefaultClient pytestmark = pytest.mark.integration @@ -70,7 +60,7 @@ def test_every_entity_has_id_and_data(): @given("a configured CBC client and tenant context") -def cbc_context(cbc_client: DefaultClient, cbc_tenant: TenantContext): +def cbc_context(cbc_client: DefaultClient, cbc_app_tenant_id: str): pass @@ -105,10 +95,10 @@ def assert_config_data_type(ctx: dict): assert config.consumption_version -@then("the tenant_context should match the configured tenant") -def assert_tenant_context(ctx: dict, cbc_tenant: TenantContext): +@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.tenant_context == cbc_tenant + assert config.app_tenant_id == cbc_app_tenant_id @then("the result should contain at least one config object") diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index fef936ab..961923b8 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -1,7 +1,8 @@ -"""Unit tests for DefaultClient and client_from_env.""" +"""Unit tests for DefaultClient and create_client.""" from __future__ import annotations +from collections.abc import Callable from typing import Any from unittest.mock import MagicMock @@ -10,17 +11,12 @@ import pytest from sap_cloud_sdk.cbc.client import DefaultClient, create_client -from sap_cloud_sdk.cbc.config import ENV_URL, ENV_CERT_PATH, ENV_KEY_PATH from sap_cloud_sdk.cbc.exceptions import ( CBCClientError, - CBCConfigError, CBCNetworkError, CBCServerError, ) -from sap_cloud_sdk.cbc._models import ( - ConfigData, - TenantContext, -) +from sap_cloud_sdk.cbc._models import ConfigData # --------------------------------------------------------------------------- @@ -28,8 +24,8 @@ # --------------------------------------------------------------------------- -def _tenant(cbc: str = "cbc-tenant", app: str = "app-tenant") -> TenantContext: - return TenantContext(cbcTenantId=cbc, appTenantId=app) +def _app_tid(value: str = "app-tenant") -> Callable[[], str]: + return lambda: value def _mock_response( @@ -49,53 +45,44 @@ def _mock_response( def _make_client( base_url: str = "https://cbc.example.ondemand.com", - tenant: TenantContext | None = None, + app_tenant_id: Callable[[], str] | None = None, ) -> tuple[DefaultClient, MagicMock]: mock_http = MagicMock(spec=httpx.Client) client = DefaultClient( - base_url=base_url, - tenant_context=tenant or _tenant(), + base_url=lambda: base_url, + app_tenant_id=app_tenant_id or _app_tid(), http_client=mock_http, ) return client, mock_http # --------------------------------------------------------------------------- -# DefaultClient — tenant_context +# DefaultClient — app_tenant_id callable # --------------------------------------------------------------------------- -class TestTenantContext: +class TestAppTenantIdCallable: def test_callable_is_invoked_on_each_call(self): call_count = 0 - def tenant_fn() -> TenantContext: + def app_tenant_id_fn() -> str: nonlocal call_count call_count += 1 - return _tenant() + 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="https://cbc.example.ondemand.com", - tenant_context=tenant_fn, + 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 - def test_raises_when_no_tenant_context(self): - mock_http = MagicMock(spec=httpx.Client) - client = DefaultClient( - base_url="https://cbc.example.ondemand.com", - http_client=mock_http, - ) - with pytest.raises(ValueError, match="tenant_context"): - client.get_consumption_versions() - # --------------------------------------------------------------------------- # DefaultClient — URL building @@ -103,10 +90,13 @@ def test_raises_when_no_tenant_context(self): class TestConfigurationsUrl: - def test_production_replaces_subdomain_with_tenant(self): - client, _ = _make_client("https://cbc.example.ondemand.com") - url = client._configurations_url(_tenant("my-tenant"), "/consumptionVersions") - assert url.startswith("https://my-tenant.") + def test_joins_base_url_and_path_verbatim(self): + client, _ = _make_client("https://my-tenant.cbc.example.ondemand.com") + url = client._configurations_url("/consumptionVersions") + assert url == ( + "https://my-tenant.cbc.example.ondemand.com" + "/configuration/v1/consumptionVersions" + ) # --------------------------------------------------------------------------- @@ -147,67 +137,98 @@ def test_raises_network_error_on_connection_failure(self): # --------------------------------------------------------------------------- -# DefaultClient — _get_entities +# DefaultClient — _get_configuration_objects # --------------------------------------------------------------------------- -class TestGetEntities: - def test_returns_parsed_entities(self): +class TestGetConfigurationObjects: + def test_returns_grouped_config_objects(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response( json_body={ "items": [ { - "entityId": "i1", - "entityName": "payment-mode", "configurationObjectId": "payment-config", - } + "entities": [{"entityId": "payment-mode"}], + }, + { + "configurationObjectId": "agent-config", + "entities": [ + {"entityId": "restaurant"}, + {"entityId": "contact"}, + ], + }, ] } ) - result = client._get_entities(_tenant(), "cv1") - assert len(result.items) == 1 - assert result.items[0].internal_id == "i1" + result = client._get_configuration_objects("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] == [ + "restaurant", + "contact", + ] # --------------------------------------------------------------------------- -# DefaultClient — _get_entity_data +# DefaultClient — _fetch_entity_data # --------------------------------------------------------------------------- -class TestGetEntityData: - def test_returns_entity_data_with_entity_id(self): +class TestFetchEntityData: + def test_reads_array_content_items(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response( - json_body={"items": [{"key": "value"}]} + json_body={ + "metadata": { + "entityName": "restaurant", + "configurationObjectId": "agent-config", + }, + "contentShape": "ARRAY", + "content": {"items": [{"name": "CBS Bistro"}], "adaptedKeys": []}, + } ) + result = client._fetch_entity_data( + "app-tenant", "cv1", "agent-config", "restaurant" + ) + assert result.entity_id == "restaurant" + assert result.data.as_list() == [{"name": "CBS Bistro"}] - result = client._get_entity_data(_tenant(), "cv1", "e1") - assert result.entity_id == "e1" - assert result.data.as_list() == [{"key": "value"}] - - def test_uses_api_metadata_entity_name_when_present(self): + def test_reads_object_content_item(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response( json_body={ "metadata": { - "entityName": "rules", - "configurationObjectId": "qualification-rules", + "entityName": "globalSettings", + "configurationObjectId": "pmc-settings", }, - "items": [{"key": "value"}], + "contentShape": "OBJECT", + "content": {"item": {"maxRetries": 3, "timeoutSeconds": 30}}, } ) + result = client._fetch_entity_data( + "app-tenant", "cv1", "pmc-settings", "globalSettings" + ) + assert result.entity_id == "globalSettings" + assert result.data.as_object() == {"maxRetries": 3, "timeoutSeconds": 30} - result = client._get_entity_data(_tenant(), "cv1", "e1") - assert result.entity_id == "rules" - assert result.data.as_list() == [{"key": "value"}] - - def test_handles_flat_list_response(self): + def test_defaults_to_empty_list_when_content_absent(self): client, mock_http = _make_client() - mock_http.request.return_value = _mock_response(json_body=[{"row": 1}]) + mock_http.request.return_value = _mock_response( + json_body={"metadata": {"entityName": "policy"}, "contentShape": "ARRAY"} + ) + result = client._fetch_entity_data( + "app-tenant", "cv1", "policy-config", "policy" + ) + assert result.data.as_list() == [] - result = client._get_entity_data(_tenant(), "cv1", "e1") - assert result.data.as_list() == [{"row": 1}] + 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("app-tenant", "cv1", "agent-config", "hours") + assert result.entity_id == "hours" # --------------------------------------------------------------------------- @@ -219,8 +240,8 @@ 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"}]}) - entities_response = _mock_response(json_body={"items": []}) - mock_http.request.side_effect = [versions_response, entities_response] + 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) @@ -235,24 +256,30 @@ def test_raises_runtime_error_when_no_versions_exist(self): def test_uses_explicit_consumption_version(self): client, mock_http = _make_client() - entities_response = _mock_response( + config_objects_response = _mock_response( json_body={ "items": [ { - "entityId": "i1", - "entityName": "payment-mode", "configurationObjectId": "payment-config", + "entities": [{"entityId": "payment-mode"}], } ] } ) - data_response = _mock_response(json_body={"items": [{"k": "v"}]}) - mock_http.request.side_effect = [entities_response, data_response] + 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" # --------------------------------------------------------------------------- @@ -269,53 +296,25 @@ def test_close_called_on_exit(self): # --------------------------------------------------------------------------- -# create_client / load_from_env +# create_client # --------------------------------------------------------------------------- class TestCreateClient: - def test_raises_config_error_when_no_env_vars(self, monkeypatch): - monkeypatch.delenv(ENV_URL, raising=False) - monkeypatch.delenv(ENV_CERT_PATH, raising=False) - monkeypatch.delenv(ENV_KEY_PATH, raising=False) - with pytest.raises(CBCConfigError): - create_client() - - def test_returns_client_for_loopback_url(self, monkeypatch): - monkeypatch.setenv(ENV_URL, "http://localhost:8001") - monkeypatch.delenv(ENV_CERT_PATH, raising=False) - monkeypatch.delenv(ENV_KEY_PATH, raising=False) - client = create_client() - assert isinstance(client, DefaultClient) - - def test_raises_config_error_for_incomplete_triplet(self, monkeypatch, tmp_path): - cert = tmp_path / "tls.crt" - cert.write_text("cert") - monkeypatch.setenv(ENV_CERT_PATH, str(cert)) - monkeypatch.delenv(ENV_KEY_PATH, raising=False) - monkeypatch.delenv(ENV_URL, raising=False) - with pytest.raises(CBCConfigError, match="incomplete"): - create_client() - - def test_raises_config_error_for_missing_cert_file(self, monkeypatch, tmp_path): - monkeypatch.setenv(ENV_CERT_PATH, str(tmp_path / "missing.crt")) - with pytest.raises(CBCConfigError, match="does not exist"): - create_client() - - def test_returns_client_with_env_var_cert_triplet(self, monkeypatch, tmp_path): - cert = tmp_path / "tls.crt" - key = tmp_path / "tls.key" - cert.write_text("cert") - key.write_text("key") - monkeypatch.setenv(ENV_CERT_PATH, str(cert)) - monkeypatch.setenv(ENV_KEY_PATH, str(key)) - monkeypatch.setenv(ENV_URL, "https://cbc.example.ondemand.com") - client = create_client() + 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_accepts_explicit_config(self): - from sap_cloud_sdk.cbc.config import CBCConfig + def test_passes_ssl_context_through(self): + import ssl - cfg = CBCConfig(base_url="http://localhost:9000") - client = create_client(config=cfg) + ctx = ssl.create_default_context() + client = create_client( + base_url=lambda: "https://cbc.example.ondemand.com", + app_tenant_id=lambda: "app-t1", + ssl_context=ctx, + ) assert isinstance(client, DefaultClient) diff --git a/tests/cbc/unit/test_client_adapter.py b/tests/cbc/unit/test_client_adapter.py new file mode 100644 index 00000000..f88fe015 --- /dev/null +++ b/tests/cbc/unit/test_client_adapter.py @@ -0,0 +1,225 @@ +"""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, + _resolve_app_tenant_id, + _resolve_base_url, + _load_ssl_context, + app_tenant_id_var, + create_agent_client, + tenant_subdomain_var, +) +from sap_cloud_sdk.cbc.client import DefaultClient +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") + + +# --------------------------------------------------------------------------- +# _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) + + +# --------------------------------------------------------------------------- +# create_agent_client +# --------------------------------------------------------------------------- + + +class TestCreateAgentClient: + def test_explicit_ssl_context_short_circuits_cert_load(self): + ctx = ssl.create_default_context() + with patch("sap_cloud_sdk.cbc.client_adapter._load_ssl_context") as load: + client = create_agent_client(ssl_context=ctx) + load.assert_not_called() + assert isinstance(client, DefaultClient) + + 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() + 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" + assert isinstance(client, DefaultClient) + + 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_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.""" + ctx = ssl.create_default_context() + client = create_agent_client(ssl_context=ctx) + + 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" diff --git a/tests/cbc/unit/test_config.py b/tests/cbc/unit/test_config.py deleted file mode 100644 index 9869b1c7..00000000 --- a/tests/cbc/unit/test_config.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Unit tests for CBC config resolution.""" - -from __future__ import annotations - -import pytest - -from sap_cloud_sdk.cbc.config import ( - ENV_CERT, - ENV_CERT_PATH, - ENV_KEY, - ENV_KEY_PATH, - ENV_URL, - _read_env_path, - load_from_env, -) -from sap_cloud_sdk.cbc.exceptions import CBCConfigError - - -# --------------------------------------------------------------------------- -# load_from_env -# --------------------------------------------------------------------------- - - -class TestLoadFromEnv: - def test_raises_when_no_env_vars(self, monkeypatch): - monkeypatch.delenv(ENV_URL, raising=False) - monkeypatch.delenv(ENV_CERT_PATH, raising=False) - monkeypatch.delenv(ENV_KEY_PATH, raising=False) - with pytest.raises(CBCConfigError): - load_from_env() - - def test_returns_config_for_url_only(self, monkeypatch): - monkeypatch.setenv(ENV_URL, "http://localhost:8001") - monkeypatch.delenv(ENV_CERT_PATH, raising=False) - monkeypatch.delenv(ENV_KEY_PATH, raising=False) - cfg = load_from_env() - assert cfg.base_url == "http://localhost:8001" - assert cfg.cert_path is None - assert cfg.key_path is None - - def test_returns_config_with_cert_triplet(self, monkeypatch, tmp_path): - cert = tmp_path / "tls.crt" - key = tmp_path / "tls.key" - cert.write_text("cert") - key.write_text("key") - monkeypatch.setenv(ENV_CERT_PATH, str(cert)) - monkeypatch.setenv(ENV_KEY_PATH, str(key)) - monkeypatch.setenv(ENV_URL, "https://cbc.example.ondemand.com") - cfg = load_from_env() - assert cfg.base_url == "https://cbc.example.ondemand.com" - assert cfg.cert_path == cert - assert cfg.key_path == key - - def test_raises_for_incomplete_triplet(self, monkeypatch, tmp_path): - cert = tmp_path / "tls.crt" - cert.write_text("cert") - monkeypatch.setenv(ENV_CERT_PATH, str(cert)) - monkeypatch.delenv(ENV_KEY_PATH, raising=False) - monkeypatch.delenv(ENV_URL, raising=False) - with pytest.raises(CBCConfigError, match="incomplete"): - load_from_env() - - def test_raises_for_incomplete_cert_pem_pair(self, monkeypatch): - monkeypatch.setenv(ENV_CERT, "-----BEGIN CERTIFICATE-----") - monkeypatch.delenv(ENV_KEY, raising=False) - monkeypatch.setenv(ENV_URL, "https://cbc.example.ondemand.com") - with pytest.raises(CBCConfigError, match="incomplete"): - load_from_env() - - def test_returns_config_with_cert_pem_pair(self, monkeypatch): - monkeypatch.setenv(ENV_CERT, "-----BEGIN CERTIFICATE-----") - monkeypatch.setenv(ENV_KEY, "-----BEGIN PRIVATE KEY-----") - monkeypatch.setenv(ENV_URL, "https://cbc.example.ondemand.com") - monkeypatch.delenv(ENV_CERT_PATH, raising=False) - monkeypatch.delenv(ENV_KEY_PATH, raising=False) - cfg = load_from_env() - assert cfg.cert_pem == "-----BEGIN CERTIFICATE-----" - assert cfg.key_pem == "-----BEGIN PRIVATE KEY-----" - assert cfg.cert_path is None - - def test_raises_for_missing_cert_file(self, monkeypatch, tmp_path): - monkeypatch.setenv(ENV_CERT_PATH, str(tmp_path / "missing.crt")) - with pytest.raises(CBCConfigError, match="does not exist"): - load_from_env() - - -# --------------------------------------------------------------------------- -# _read_env_path -# --------------------------------------------------------------------------- - - -class TestReadEnvPath: - def test_returns_none_when_unset(self, monkeypatch): - monkeypatch.delenv("MY_PATH", raising=False) - assert _read_env_path("MY_PATH") is None - - def test_returns_path_when_file_exists(self, monkeypatch, tmp_path): - p = tmp_path / "file.pem" - p.write_text("x") - monkeypatch.setenv("MY_PATH", str(p)) - assert _read_env_path("MY_PATH") == p - - def test_raises_when_file_missing(self, monkeypatch, tmp_path): - monkeypatch.setenv("MY_PATH", str(tmp_path / "missing.pem")) - with pytest.raises(CBCConfigError, match="does not exist"): - _read_env_path("MY_PATH") diff --git a/tests/cbc/unit/test_models.py b/tests/cbc/unit/test_models.py index 85e9f267..eb37624d 100644 --- a/tests/cbc/unit/test_models.py +++ b/tests/cbc/unit/test_models.py @@ -14,30 +14,9 @@ ConsumptionVersions, EntityContent, EntityData, - TenantContext, ) -# --------------------------------------------------------------------------- -# TenantContext -# --------------------------------------------------------------------------- - - -class TestTenantContext: - def test_accepts_camel_case_aliases(self): - ctx = TenantContext(cbcTenantId="cbc-1", appTenantId="app-1") - assert ctx.cbc_tenant_id == "cbc-1" - assert ctx.app_tenant_id == "app-1" - - def test_accepts_snake_case_names(self): - ctx = TenantContext(cbc_tenant_id="cbc-1", app_tenant_id="app-1") - assert ctx.cbc_tenant_id == "cbc-1" - - def test_rejects_empty_cbc_tenant_id(self): - with pytest.raises(Exception): - TenantContext(cbcTenantId="", appTenantId="app-1") - - # --------------------------------------------------------------------------- # ConsumptionVersions.latest() # --------------------------------------------------------------------------- @@ -116,6 +95,20 @@ def test_as_object_raises_when_list(self): 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 @@ -135,7 +128,7 @@ def _config_object(self, config_object_id: str, *entity_ids: str) -> ConfigObjec def _config(self, *config_objects: ConfigObject) -> ConfigData: return ConfigData( consumption_version="cv1", - tenant_context=TenantContext(cbcTenantId="t1", appTenantId="app-t1"), + app_tenant_id="app-t1", config_objects=list(config_objects), ) From baaf71f7e8b421f7acbf9ed9e8fac8367f47184c Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sat, 3 Oct 2026 15:16:32 +0530 Subject: [PATCH 09/14] perf(cbc): resolve base_url once per get_configuration call get_configuration previously re-invoked the base_url callable on every HTTP request (consumption versions + config objects + one per entity), so a single call triggered 2+N tenant-mapping fragment lookups in the platform adapter. Resolve base_url once at the top of the public call and thread the resolved string into the internal methods (_get_configuration_objects, _fetch_entity_data, _configurations_url), mirroring how app_tenant_id is already threaded. One operation now does one base_url resolution. Also fix incoherent unit-test data: agent-config no longer holds restaurant/contact/hours entities; replaced with tax-config / tax-category / tax-rate to match the finance-domain examples used elsewhere. --- src/sap_cloud_sdk/cbc/client.py | 18 ++++-- tests/cbc/unit/test_client.py | 97 +++++++++++++++++++++++++++------ 2 files changed, 94 insertions(+), 21 deletions(-) diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index 91490728..004451ca 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -172,14 +172,16 @@ def get_consumption_versions(self) -> ConsumptionVersions: CBCServerError: On 5xx responses. CBCNetworkError: On connection failures. """ + base_url = self._base_url() app_tenant_id = self._resolve_app_tenant_id() 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, app_tenant_id: str, consumption_version: str + self, base_url: str, app_tenant_id: str, consumption_version: str ) -> ConfigObjectList: """Return the config objects (with their entities) for the given version. @@ -187,6 +189,7 @@ def _get_configuration_objects( 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. @@ -199,6 +202,7 @@ def _get_configuration_objects( CBCNetworkError: On connection failures. """ url = self._configurations_url( + base_url, f"/consumptionVersions/{consumption_version}/configurationObjects" f"?appTenantId={app_tenant_id}", ) @@ -229,6 +233,7 @@ def get_configuration( 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() @@ -239,12 +244,15 @@ def get_configuration( ) consumption_version = latest.version - co_list = self._get_configuration_objects(app_tenant_id, consumption_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, @@ -267,12 +275,14 @@ def get_configuration( 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}", @@ -291,8 +301,8 @@ def _fetch_entity_data( resolved_id = api_meta.get("entityName") or entity_id return EntityData(entity_id=resolved_id, data=EntityContent(raw_data)) - def _configurations_url(self, path: str = "") -> str: - base = self._base_url().rstrip("/") + def _configurations_url(self, base_url: str, path: str = "") -> str: + base = base_url.rstrip("/") return f"{base}{self._config.configurations_path}{path}" def _request( diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index 961923b8..14cdf546 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -92,7 +92,9 @@ def app_tenant_id_fn() -> str: 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("/consumptionVersions") + url = client._configurations_url( + "https://my-tenant.cbc.example.ondemand.com", "/consumptionVersions" + ) assert url == ( "https://my-tenant.cbc.example.ondemand.com" "/configuration/v1/consumptionVersions" @@ -152,21 +154,23 @@ def test_returns_grouped_config_objects(self): "entities": [{"entityId": "payment-mode"}], }, { - "configurationObjectId": "agent-config", + "configurationObjectId": "tax-config", "entities": [ - {"entityId": "restaurant"}, - {"entityId": "contact"}, + {"entityId": "tax-category"}, + {"entityId": "tax-rate"}, ], }, ] } ) - result = client._get_configuration_objects("app-tenant", "cv1") + 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] == [ - "restaurant", - "contact", + "tax-category", + "tax-rate", ] @@ -181,18 +185,22 @@ def test_reads_array_content_items(self): mock_http.request.return_value = _mock_response( json_body={ "metadata": { - "entityName": "restaurant", - "configurationObjectId": "agent-config", + "entityName": "tax-category", + "configurationObjectId": "tax-config", }, "contentShape": "ARRAY", - "content": {"items": [{"name": "CBS Bistro"}], "adaptedKeys": []}, + "content": {"items": [{"code": "STD"}], "adaptedKeys": []}, } ) result = client._fetch_entity_data( - "app-tenant", "cv1", "agent-config", "restaurant" + "https://cbc.example.ondemand.com", + "app-tenant", + "cv1", + "tax-config", + "tax-category", ) - assert result.entity_id == "restaurant" - assert result.data.as_list() == [{"name": "CBS Bistro"}] + 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() @@ -207,7 +215,11 @@ def test_reads_object_content_item(self): } ) result = client._fetch_entity_data( - "app-tenant", "cv1", "pmc-settings", "globalSettings" + "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} @@ -218,7 +230,11 @@ def test_defaults_to_empty_list_when_content_absent(self): json_body={"metadata": {"entityName": "policy"}, "contentShape": "ARRAY"} ) result = client._fetch_entity_data( - "app-tenant", "cv1", "policy-config", "policy" + "https://cbc.example.ondemand.com", + "app-tenant", + "cv1", + "policy-config", + "policy", ) assert result.data.as_list() == [] @@ -227,8 +243,14 @@ def test_falls_back_to_path_entity_id_without_metadata(self): mock_http.request.return_value = _mock_response( json_body={"contentShape": "ARRAY", "content": {"items": []}} ) - result = client._fetch_entity_data("app-tenant", "cv1", "agent-config", "hours") - assert result.entity_id == "hours" + result = client._fetch_entity_data( + "https://cbc.example.ondemand.com", + "app-tenant", + "cv1", + "tax-config", + "tax-rate", + ) + assert result.entity_id == "tax-rate" # --------------------------------------------------------------------------- @@ -281,6 +303,47 @@ def test_uses_explicit_consumption_version(self): 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 From 24c59d7ecf7d77d87a1b7991adc015c819653883 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sat, 3 Oct 2026 16:03:04 +0530 Subject: [PATCH 10/14] fix(cbc): wrap destination errors as CBCConfigError in adapter The adapter's _resolve_base_url and _load_ssl_context call Destination Service APIs that can raise DestinationError, which leaked past the documented CBCConfigError contract. Wrap both in try/except, re-raising as CBCConfigError with a descriptive message and chained cause. --- src/sap_cloud_sdk/cbc/client_adapter.py | 30 ++++++++++++++++------- tests/cbc/unit/test_client_adapter.py | 32 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/sap_cloud_sdk/cbc/client_adapter.py b/src/sap_cloud_sdk/cbc/client_adapter.py index 2af61e8e..d9366f58 100644 --- a/src/sap_cloud_sdk/cbc/client_adapter.py +++ b/src/sap_cloud_sdk/cbc/client_adapter.py @@ -161,6 +161,7 @@ def _resolve_base_url(destination_instance: str) -> str: 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: @@ -170,8 +171,14 @@ def _resolve_base_url(destination_instance: str) -> str: ) app_tenant_id = _resolve_app_tenant_id() - client = create_fragment_client(instance=destination_instance) - fragments = client.list_subaccount_fragments(tenant=subdomain) + 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 @@ -207,16 +214,23 @@ def _load_ssl_context( p12_password: Certificate keystore password, or ``None`` if unencrypted. Raises: - CBCConfigError: If the certificate is not found. + 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 - cert = create_certificate_client( - instance=destination_instance - ).get_subaccount_certificate( - cert_name, access_strategy=AccessStrategy.PROVIDER_ONLY - ) + 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 " diff --git a/tests/cbc/unit/test_client_adapter.py b/tests/cbc/unit/test_client_adapter.py index f88fe015..dbb2e8e1 100644 --- a/tests/cbc/unit/test_client_adapter.py +++ b/tests/cbc/unit/test_client_adapter.py @@ -117,6 +117,22 @@ def test_raises_when_cbc_url_missing_from_fragment(self): 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 @@ -157,6 +173,22 @@ def test_raises_when_cert_not_found(self): 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 From fd6ded7fd984db96372f01b5ea462f7ea29a0c70 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sun, 4 Oct 2026 10:30:44 +0530 Subject: [PATCH 11/14] feat(cbc): reload rotated mTLS certificate on TLS handshake failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter loaded the mTLS certificate once in create_agent_client and baked it into the httpx.Client at construction, so a rotated or expired certificate killed a long-lived client until the process restarted — violating the "Credential Binding Rotation" guideline that every module reading credentials must recover from rotation. Make the core reload reactively. ssl_context becomes a Callable[[], ssl.SSLContext] | None, resolved once at construction and re-invoked only when a request fails the TLS handshake. On such a failure the client rebuilds its httpx.Client from a fresh context under a lock (guarding against a thundering herd) and retries the one request once; a second failure, a non-TLS transport error, or a failing rebuild (the cert loader raises CBCConfigError) propagates cleanly, leaving the previous working client in place. TLS failures are detected by walking the exception's __cause__/__context__ chain for ssl.SSLError, since httpx surfaces an expired client cert as ReadError wrapping ssl.SSLError two levels down. Mirrors objectstore's _execute_with_retry rotation pattern. create_agent_client drops its ssl_context parameter and now owns cert resolution end-to-end, passing a lambda: load_ssl_context(...) factory to the core. Expose the three platform resolvers as public composition helpers (resolve_base_url, resolve_app_tenant_id, load_ssl_context) at the top level, so a caller can keep most of the platform preset but override a single axis via create_client instead of reimplementing the resolvers. Also: - resolve base_url + app_tenant_id once per get_configuration on the auto-version path (extract _get_consumption_versions taking resolved values), avoiding a double resolve that could also disagree if the request context changed between the two. - rename _build_client/_rebuild_client/self._client to the _http_client forms, so names disambiguate the httpx client from the CBC client. - build the client with verify=ctx if ctx is not None else True, making it explicit that TLS verification is never disabled. --- src/sap_cloud_sdk/cbc/__init__.py | 9 +- src/sap_cloud_sdk/cbc/client.py | 167 ++++++++++++++++++---- src/sap_cloud_sdk/cbc/client_adapter.py | 80 ++++++----- src/sap_cloud_sdk/cbc/user-guide.md | 44 ++++-- tests/cbc/integration/conftest.py | 3 +- tests/cbc/unit/test_client.py | 178 +++++++++++++++++++++++- tests/cbc/unit/test_client_adapter.py | 85 ++++++----- 7 files changed, 458 insertions(+), 108 deletions(-) diff --git a/src/sap_cloud_sdk/cbc/__init__.py b/src/sap_cloud_sdk/cbc/__init__.py index 28165d6f..fd55c66e 100644 --- a/src/sap_cloud_sdk/cbc/__init__.py +++ b/src/sap_cloud_sdk/cbc/__init__.py @@ -13,7 +13,7 @@ cbc_client = cbc.create_client( base_url=lambda: resolve_cbc_url(), app_tenant_id=lambda: resolve_app_tenant_id(), - ssl_context=ssl_ctx, + ssl_context=lambda: build_ssl_ctx(), ) config = cbc_client.get_configuration() @@ -40,6 +40,9 @@ 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.exceptions import ( @@ -74,6 +77,10 @@ "app_tenant_id_var", "tenant_subdomain_var", "CBC_FRAGMENT_PREFIX", + # platform resolvers (compose with create_client to override one axis) + "resolve_base_url", + "resolve_app_tenant_id", + "load_ssl_context", # exceptions "CBCError", "CBCClientError", diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index 004451ca..995c1f10 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -8,7 +8,7 @@ - :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 +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. @@ -19,7 +19,7 @@ cbc_client = cbc.create_client( base_url=lambda: resolve_cbc_url(), app_tenant_id=lambda: resolve_app_tenant_id(), - ssl_context=ssl_ctx, + ssl_context=lambda: build_ssl_ctx(), ) config = cbc_client.get_configuration() """ @@ -28,6 +28,7 @@ import logging import ssl +import threading from collections.abc import Callable from dataclasses import dataclass from typing import Any, Protocol @@ -54,6 +55,26 @@ 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) # --------------------------------------------------------------------------- @@ -70,7 +91,7 @@ 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 + for an app tenant at a point in time. Use this to discover the active version ID when you don't already have it. """ ... @@ -112,21 +133,25 @@ class DefaultClient: cbc_client = cbc.create_client( base_url=lambda: resolve_cbc_url(), app_tenant_id=lambda: resolve_app_tenant_id(), - ssl_context=ssl_ctx, + 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 + 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 pre-built :class:`ssl.SSLContext` with mTLS loaded. + 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__( @@ -134,18 +159,30 @@ def __init__( base_url: Callable[[], str], app_tenant_id: Callable[[], str], http_client: httpx.Client | None = None, - ssl_context: ssl.SSLContext | None = None, + ssl_context: Callable[[], ssl.SSLContext] | None = None, ) -> None: self._base_url = base_url self._app_tenant_id = app_tenant_id self._config = _ClientConfig( configurations_path="/configuration/v1", ) - self._client = http_client or httpx.Client(verify=ssl_context or True) + 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._client.close() + self._http_client.close() def __enter__(self) -> "DefaultClient": return self @@ -172,8 +209,30 @@ def get_consumption_versions(self) -> ConsumptionVersions: CBCServerError: On 5xx responses. CBCNetworkError: On connection failures. """ - base_url = self._base_url() - app_tenant_id = self._resolve_app_tenant_id() + 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}", @@ -216,12 +275,12 @@ def get_configuration( """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 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 + consumption_version: Consumption version ID. When ``None``, the latest version is resolved via :meth:`get_consumption_versions`. Returns: @@ -236,7 +295,7 @@ def get_configuration( base_url = self._base_url() app_tenant_id = self._resolve_app_tenant_id() if consumption_version is None: - versions = self.get_consumption_versions() + versions = self._get_consumption_versions(base_url, app_tenant_id) latest = versions.latest() if latest is None: raise CBCClientError( @@ -313,20 +372,23 @@ def _request( ) -> httpx.Response: logger.debug("CBC %s %s", method, url) try: - response = self._client.request( - method=method, - url=url, - headers={}, - json=body, - timeout=30.0, - ) + 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 CBCNetworkError( - f"Network error calling CBC: {exc}", - http_context=HttpContext( - status_code=-1, request_method=method, request_url=url - ), - ) from exc + raise self._network_error(method, url, exc) from exc if response.status_code >= 400: ctx = HttpContext( @@ -342,6 +404,48 @@ def _request( 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 @@ -352,7 +456,7 @@ def create_client( *, base_url: Callable[[], str], app_tenant_id: Callable[[], str], - ssl_context: ssl.SSLContext | None = None, + ssl_context: Callable[[], ssl.SSLContext] | None = None, ) -> CBCClient: """Create a :class:`DefaultClient`. @@ -360,7 +464,10 @@ def create_client( 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 pre-built :class:`ssl.SSLContext` with mTLS loaded. + 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`. diff --git a/src/sap_cloud_sdk/cbc/client_adapter.py b/src/sap_cloud_sdk/cbc/client_adapter.py index d9366f58..1149afde 100644 --- a/src/sap_cloud_sdk/cbc/client_adapter.py +++ b/src/sap_cloud_sdk/cbc/client_adapter.py @@ -21,9 +21,10 @@ not know *how* those values are obtained — authentication and the request pipeline stay with the app. -The mTLS certificate is loaded **once** when the client is built. Platform -certificates are typically short-lived, so recreate the client before the -certificate expires to pick up the rotated certificate. +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:: @@ -105,7 +106,7 @@ def _default_cert_name() -> str: 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 / ssl_context to create_agent_client()." + f"Set it, or pass cbc_cert_name to create_agent_client()." ) return _CERT_NAME_TEMPLATE.format(landscape=landscape) @@ -121,9 +122,15 @@ def _cert_password_from_env() -> bytes | None: # --------------------------------------------------------------------------- -def _resolve_app_tenant_id() -> str: +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. @@ -139,9 +146,15 @@ def _resolve_app_tenant_id() -> str: return app_tid -def _resolve_base_url(destination_instance: str) -> str: +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. @@ -169,7 +182,7 @@ def _resolve_base_url(destination_instance: str) -> str: "cbc_tenant_subdomain ContextVar is empty — set tenant_subdomain_var " "from the dwc-subdomain header." ) - app_tenant_id = _resolve_app_tenant_id() + app_tenant_id = resolve_app_tenant_id() try: client = create_fragment_client(instance=destination_instance) @@ -201,11 +214,16 @@ def _resolve_base_url(destination_instance: str) -> str: return cbc_url # cbcTid already baked in — used verbatim -def _load_ssl_context( +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 @@ -246,7 +264,6 @@ def _load_ssl_context( def create_agent_client( *, - ssl_context: ssl.SSLContext | None = None, destination_instance: str | None = None, cbc_cert_name: str | None = None, p12_password: bytes | None = None, @@ -255,15 +272,15 @@ def create_agent_client( 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 once from the - Destination Service. Because the certificate is loaded once, recreate the - client before the certificate expires so it picks up the rotated - certificate. + 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: - ssl_context: Pre-built :class:`ssl.SSLContext`. When given, the - certificate load is skipped entirely (use for startup injection or - tests). destination_instance: The ``instance`` passed to the destination ``create_fragment_client`` / ``create_certificate_client`` (used for secret resolution in cloud mode). Defaults to the @@ -278,27 +295,24 @@ def create_agent_client( A configured CBC client. Raises: - CBCConfigError: If the certificate cannot be resolved at construction - time (unless ``ssl_context`` is supplied), or — when a request is - made — if a ContextVar is empty or the tenant-mapping fragment is - missing. + 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 destination_instance is None: destination_instance = os.environ.get(ENV_DESTINATION_INSTANCE, "default") - if ssl_context is None: - resolved_cert_name = ( - cbc_cert_name or os.environ.get(ENV_CERT_NAME) or _default_cert_name() - ) - resolved_password = ( - p12_password if p12_password is not None else _cert_password_from_env() - ) - ssl_context = _load_ssl_context( - destination_instance, resolved_cert_name, resolved_password - ) + resolved_cert_name = ( + cbc_cert_name or os.environ.get(ENV_CERT_NAME) or _default_cert_name() + ) + resolved_password = ( + p12_password if 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=ssl_context, + 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/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index f77f1f3c..7f897629 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -31,10 +31,14 @@ 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=ssl_ctx, + 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). @@ -176,12 +180,14 @@ 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 once at startup. +fetched from the Destination Service. -**Certificate rotation.** The mTLS certificate is loaded once when the client is -built. Platform certificates are typically short-lived, so recreate the client — -call `create_agent_client()` again — before the certificate expires to pick up -the rotated certificate. +**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 @@ -194,8 +200,30 @@ Defaults cover the common case; override via env when needed: | `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 | -An explicit `ssl_context=` passed to `create_agent_client` short-circuits the -certificate load entirely. +### 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 diff --git a/tests/cbc/integration/conftest.py b/tests/cbc/integration/conftest.py index b7fbf033..7bea7835 100644 --- a/tests/cbc/integration/conftest.py +++ b/tests/cbc/integration/conftest.py @@ -56,8 +56,9 @@ def cbc_client() -> CBCClient: 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=_build_ssl_context(), + ssl_context=(lambda: ctx) if ctx is not None else None, ) diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index 14cdf546..800ef1d0 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -9,10 +9,12 @@ import httpx import json import pytest +import ssl -from sap_cloud_sdk.cbc.client import DefaultClient, create_client +from sap_cloud_sdk.cbc.client import DefaultClient, create_client, _is_tls_failure from sap_cloud_sdk.cbc.exceptions import ( CBCClientError, + CBCConfigError, CBCNetworkError, CBCServerError, ) @@ -378,6 +380,178 @@ def test_passes_ssl_context_through(self): client = create_client( base_url=lambda: "https://cbc.example.ondemand.com", app_tenant_id=lambda: "app-t1", - ssl_context=ctx, + 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() -> MagicMock: + 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 # type: ignore[method-assign] + 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() -> MagicMock: + mock = orig_build() + mock.request.return_value = ok + return mock + + client._build_http_client = build_then_prime # type: ignore[method-assign] + + 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() -> MagicMock: + mock = orig_build() + mock.request.side_effect = _tls_read_error() + return mock + + client._build_http_client = build_then_fail # type: ignore[method-assign] + + 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 index dbb2e8e1..2031d0c1 100644 --- a/tests/cbc/unit/test_client_adapter.py +++ b/tests/cbc/unit/test_client_adapter.py @@ -12,14 +12,14 @@ ENV_CERT_NAME, ENV_DESTINATION_INSTANCE, ENV_LANDSCAPE, - _resolve_app_tenant_id, - _resolve_base_url, - _load_ssl_context, 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 +from sap_cloud_sdk.cbc.client import DefaultClient, create_client from sap_cloud_sdk.cbc.exceptions import CBCConfigError @@ -31,22 +31,22 @@ def _reset_contextvars(): # --------------------------------------------------------------------------- -# _resolve_app_tenant_id +# 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" + 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_app_tenant_id() # --------------------------------------------------------------------------- -# _resolve_base_url +# resolve_base_url # --------------------------------------------------------------------------- @@ -70,7 +70,7 @@ def test_reads_cbc_url_from_matching_fragment(self): "sap_cloud_sdk.destination.create_fragment_client", return_value=fake_client, ): - url = _resolve_base_url("default") + url = resolve_base_url("default") assert url == "https://cbc.example.cloud.sap" fake_client.list_subaccount_fragments.assert_called_once_with( @@ -80,12 +80,12 @@ def test_reads_cbc_url_from_matching_fragment(self): 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") + 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") + resolve_base_url("default") def test_raises_when_no_matching_fragment(self): tenant_subdomain_var.set("appfnd-subscriber") @@ -100,7 +100,7 @@ def test_raises_when_no_matching_fragment(self): return_value=fake_client, ): with pytest.raises(CBCConfigError, match="No CBC mapping fragment"): - _resolve_base_url("default") + resolve_base_url("default") def test_raises_when_cbc_url_missing_from_fragment(self): tenant_subdomain_var.set("appfnd-subscriber") @@ -115,7 +115,7 @@ def test_raises_when_cbc_url_missing_from_fragment(self): return_value=fake_client, ): with pytest.raises(CBCConfigError, match="no 'cbcUrl'"): - _resolve_base_url("default") + resolve_base_url("default") def test_wraps_destination_error_as_config_error(self): from sap_cloud_sdk.destination.exceptions import DestinationOperationError @@ -131,11 +131,11 @@ def test_wraps_destination_error_as_config_error(self): return_value=fake_client, ): with pytest.raises(CBCConfigError, match="Could not resolve the CBC URL"): - _resolve_base_url("default") + resolve_base_url("default") # --------------------------------------------------------------------------- -# _load_ssl_context +# load_ssl_context # --------------------------------------------------------------------------- @@ -158,7 +158,7 @@ def test_loads_cert_into_ssl_context(self): return_value=ctx, ) as load_pem, ): - result = _load_ssl_context("default", "my-cert.pem", b"secret") + 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") @@ -171,7 +171,7 @@ def test_raises_when_cert_not_found(self): return_value=fake_cert_client, ): with pytest.raises(CBCConfigError, match="not found"): - _load_ssl_context("default", "missing.pem", None) + load_ssl_context("default", "missing.pem", None) def test_wraps_destination_error_as_config_error(self): from sap_cloud_sdk.destination.exceptions import DestinationOperationError @@ -187,7 +187,7 @@ def test_wraps_destination_error_as_config_error(self): with pytest.raises( CBCConfigError, match="Could not fetch the mTLS certificate" ): - _load_ssl_context("default", "my-cert.pem", None) + load_ssl_context("default", "my-cert.pem", None) # --------------------------------------------------------------------------- @@ -196,31 +196,29 @@ def test_wraps_destination_error_as_config_error(self): class TestCreateAgentClient: - def test_explicit_ssl_context_short_circuits_cert_load(self): - ctx = ssl.create_default_context() - with patch("sap_cloud_sdk.cbc.client_adapter._load_ssl_context") as load: - client = create_agent_client(ssl_context=ctx) - load.assert_not_called() - assert isinstance(client, DefaultClient) - 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", + "sap_cloud_sdk.cbc.client_adapter.load_ssl_context", return_value=ssl.create_default_context(), ) as load: client = create_agent_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 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). + client._ssl_factory() + assert load.call_count == 2 assert isinstance(client, DefaultClient) 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", + "sap_cloud_sdk.cbc.client_adapter.load_ssl_context", return_value=ssl.create_default_context(), ) as load: create_agent_client() @@ -236,8 +234,12 @@ def test_raises_when_landscape_unset_and_no_cert_name(self, monkeypatch): def test_wires_resolvers_into_client(self, monkeypatch): """The two ContextVars drive base_url + app_tenant_id at request time.""" - ctx = ssl.create_default_context() - client = create_agent_client(ssl_context=ctx) + 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() app_tenant_id_var.set("app-t1") assert client._resolve_app_tenant_id() == "app-t1" @@ -255,3 +257,20 @@ def test_wires_resolvers_into_client(self, monkeypatch): "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) From f8f1e516f3afda404224fc6886ab32f186144dfc Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sun, 4 Oct 2026 10:39:02 +0530 Subject: [PATCH 12/14] chore(cbc): bump version to 0.58.0 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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" }, From 358ea78c27e37f3f9607a823d74fe9ca35cfd6c9 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sun, 4 Oct 2026 10:50:15 +0530 Subject: [PATCH 13/14] test(cbc): fix ty type errors in rotation and adapter tests The pre-commit ty hook checks tests/ (unlike an src-only ty run), which surfaced 10 diagnostics in the CBC test suite. test_client_adapter.py: create_agent_client() returns the CBCClient Protocol, which has no private members, so accessing _ssl_factory / _base_url / _resolve_app_tenant_id failed. Narrow to DefaultClient with an isinstance assert before touching privates, and guard the optional _ssl_factory before calling it. test_client.py: orig_build = client._build_http_client is typed () -> httpx.Client, so orig_build() was Client, not MagicMock, breaking the .request wiring and the -> MagicMock return annotations. Align the build helpers' return types with the method they replace (httpx.Client), cast() the mock where .request is wired, and replace the ineffective mypy # type: ignore[method-assign] with the repo's ty idiom # ty: ignore[invalid-assignment]. --- tests/cbc/unit/test_client.py | 18 +++++++++--------- tests/cbc/unit/test_client_adapter.py | 4 +++- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index 800ef1d0..9b036719 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any +from typing import Any, cast from unittest.mock import MagicMock import httpx @@ -435,7 +435,7 @@ def _rotating_client( """ built: list[MagicMock] = [] - def build() -> 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) @@ -449,7 +449,7 @@ def build() -> MagicMock: http_client=initial, ssl_context=factory, ) - client._build_http_client = build # type: ignore[method-assign] + client._build_http_client = build # ty: ignore[invalid-assignment] return client, built def test_reactive_success_rebuilds_and_retries(self): @@ -465,12 +465,12 @@ def factory() -> ssl.SSLContext: # the rebuilt client answers ok; wire it the moment it is created orig_build = client._build_http_client - def build_then_prime() -> MagicMock: - mock = orig_build() + 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 # type: ignore[method-assign] + client._build_http_client = build_then_prime # ty: ignore[invalid-assignment] result = client.get_consumption_versions() @@ -487,12 +487,12 @@ def factory() -> ssl.SSLContext: built[0].request.side_effect = _tls_read_error() orig_build = client._build_http_client - def build_then_fail() -> MagicMock: - mock = orig_build() + 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 # type: ignore[method-assign] + client._build_http_client = build_then_fail # ty: ignore[invalid-assignment] with pytest.raises(CBCNetworkError): client.get_consumption_versions() diff --git a/tests/cbc/unit/test_client_adapter.py b/tests/cbc/unit/test_client_adapter.py index 2031d0c1..f96ddd0f 100644 --- a/tests/cbc/unit/test_client_adapter.py +++ b/tests/cbc/unit/test_client_adapter.py @@ -203,6 +203,7 @@ def test_builds_ssl_context_from_cert_default(self, monkeypatch): 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() @@ -210,9 +211,9 @@ def test_builds_ssl_context_from_cert_default(self, monkeypatch): 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 - assert isinstance(client, DefaultClient) def test_env_overrides_apply(self, monkeypatch): monkeypatch.setenv(ENV_DESTINATION_INSTANCE, "cbc-instance") @@ -240,6 +241,7 @@ def test_wires_resolvers_into_client(self, monkeypatch): 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" From 894ec7d01454228d8d7ead6296c2954f8cb61968 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sun, 4 Oct 2026 12:59:03 +0530 Subject: [PATCH 14/14] feat(cbc): add CBCDestinationConfig for the platform adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the three loose keyword args on create_agent_client (destination_instance, cbc_cert_name, p12_password) with a single CBCDestinationConfig settings object, addressing the PR review request for a config object. The object lives in a new cbc/config.py, matching the dedicated-config.py convention of the agw, adms, print, and destination modules. Per-field env overrides are unchanged: a value set on the config wins over its CLOUD_SDK_CBC_* env var, which in turn falls back to the platform default. Scoped to the adapter layer only — the core create_client and its hardcoded timeout are untouched; a core config object is deferred. Also rename the private _ClientConfig (API-path holder) to _ApiPaths so it no longer reads as a near-homonym of the new public config class. --- src/sap_cloud_sdk/cbc/__init__.py | 4 +++ src/sap_cloud_sdk/cbc/client.py | 4 +-- src/sap_cloud_sdk/cbc/client_adapter.py | 29 +++++++++++----------- src/sap_cloud_sdk/cbc/config.py | 30 ++++++++++++++++++++++ src/sap_cloud_sdk/cbc/user-guide.md | 13 ++++++++++ tests/cbc/unit/test_client_adapter.py | 33 +++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 src/sap_cloud_sdk/cbc/config.py diff --git a/src/sap_cloud_sdk/cbc/__init__.py b/src/sap_cloud_sdk/cbc/__init__.py index fd55c66e..849e17f9 100644 --- a/src/sap_cloud_sdk/cbc/__init__.py +++ b/src/sap_cloud_sdk/cbc/__init__.py @@ -45,6 +45,9 @@ resolve_base_url, tenant_subdomain_var, ) +from sap_cloud_sdk.cbc.config import ( + CBCDestinationConfig, +) from sap_cloud_sdk.cbc.exceptions import ( CBCError, CBCClientError, @@ -77,6 +80,7 @@ "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", diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index 995c1f10..9d3376eb 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -114,7 +114,7 @@ def get_configuration( @dataclass(frozen=True) -class _ClientConfig: +class _ApiPaths: """API path configuration for a :class:`DefaultClient` instance.""" configurations_path: str @@ -163,7 +163,7 @@ def __init__( ) -> None: self._base_url = base_url self._app_tenant_id = app_tenant_id - self._config = _ClientConfig( + self._config = _ApiPaths( configurations_path="/configuration/v1", ) self._ssl_factory = ssl_context diff --git a/src/sap_cloud_sdk/cbc/client_adapter.py b/src/sap_cloud_sdk/cbc/client_adapter.py index 1149afde..f1b533ed 100644 --- a/src/sap_cloud_sdk/cbc/client_adapter.py +++ b/src/sap_cloud_sdk/cbc/client_adapter.py @@ -44,6 +44,7 @@ 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 # --------------------------------------------------------------------------- @@ -264,9 +265,7 @@ def load_ssl_context( def create_agent_client( *, - destination_instance: str | None = None, - cbc_cert_name: str | None = None, - p12_password: bytes | None = None, + config: CBCDestinationConfig | None = None, ) -> CBCClient: """Create a CBC client wired for the application platform. @@ -281,15 +280,11 @@ def create_agent_client( use the generic :func:`~sap_cloud_sdk.cbc.client.create_client` directly. Args: - destination_instance: The ``instance`` passed to the destination - ``create_fragment_client`` / ``create_certificate_client`` (used for - secret resolution in cloud mode). Defaults to the - ``CLOUD_SDK_CBC_DESTINATION_INSTANCE`` env var, else ``"default"``. - cbc_cert_name: Name of the app's mTLS certificate. Defaults to the - ``CLOUD_SDK_CBC_CERTIFICATE_NAME`` env var, else derived from the - platform landscape (``APPFND_CONHOS_LANDSCAPE``). - p12_password: Certificate keystore password. Defaults to the - ``CLOUD_SDK_CBC_P12_PASSWORD`` env var, else ``None``. + 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. @@ -299,14 +294,20 @@ def create_agent_client( 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 = ( - cbc_cert_name or os.environ.get(ENV_CERT_NAME) or _default_cert_name() + config.cbc_cert_name or os.environ.get(ENV_CERT_NAME) or _default_cert_name() ) resolved_password = ( - p12_password if p12_password is not None else _cert_password_from_env() + config.p12_password + if config.p12_password is not None + else _cert_password_from_env() ) return create_client( 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/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index 7f897629..bc6f9623 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -200,6 +200,19 @@ Defaults cover the common case; override via env when needed: | `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`, diff --git a/tests/cbc/unit/test_client_adapter.py b/tests/cbc/unit/test_client_adapter.py index f96ddd0f..afe6e02e 100644 --- a/tests/cbc/unit/test_client_adapter.py +++ b/tests/cbc/unit/test_client_adapter.py @@ -20,6 +20,7 @@ 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 @@ -227,6 +228,38 @@ def test_env_overrides_apply(self, monkeypatch): 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)