From 025884707dedc5bd32801daf058dad1d325a52d6 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sun, 13 Sep 2026 23:12:17 +0530 Subject: [PATCH 01/26] 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 5832a4f7..3df4d48d 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 9fd189812853a4f5f5fa3fb54a6b085a9edadcb2 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Mon, 14 Sep 2026 19:39:23 +0530 Subject: [PATCH 02/26] 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 5cd77659059382d14cbb4f4a308e1a3f7def6d48 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Mon, 14 Sep 2026 20:57:22 +0530 Subject: [PATCH 03/26] =?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 aeb10a1a..fe237ea8 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 + 7 agentgateway + 13 agent_memory - # + 5 data_anonymization + 52 adms + 6 print + 94 dpi_ng + 1 bootstrap + 3 output_management = 256 - assert len(all_operations) == 256 + # + 5 data_anonymization + 52 adms + 6 print + 94 dpi_ng + 1 bootstrap + 3 output_management + # + 2 cbc = 258 + assert len(all_operations) == 258 From 67590da9a42561d044afccbc41a4b5d084d337e2 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Wed, 16 Sep 2026 10:06:15 +0530 Subject: [PATCH 04/26] 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 59969a6fe49ae98866167398348e25f770241fe9 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Fri, 18 Sep 2026 23:10:41 +0530 Subject: [PATCH 05/26] 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 841a550b3d591db5d3c8f33c4e63660a73d2e2e2 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Fri, 18 Sep 2026 23:14:32 +0530 Subject: [PATCH 06/26] 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 26b80a9bf0e0cec85eda0f6640fcc3dfcbe6662f Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Fri, 18 Sep 2026 23:30:22 +0530 Subject: [PATCH 07/26] 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 5fe5d9a16fc328c2038646649f2a02f1679036bc Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sat, 3 Oct 2026 13:44:25 +0530 Subject: [PATCH 08/26] =?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 21dd267e8f78430cf1a8e6743578b9f59da57ad7 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sat, 3 Oct 2026 15:16:32 +0530 Subject: [PATCH 09/26] 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 6bff82fe3f43159af5be4e0d330517941af65dab Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sat, 3 Oct 2026 16:03:04 +0530 Subject: [PATCH 10/26] 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 e33070ffb8bc564d9e7dc9c9b5cc0b4c71003833 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sun, 4 Oct 2026 10:30:44 +0530 Subject: [PATCH 11/26] 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 541f6153da4e7aa25559a71ab6213141630f8d66 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sun, 4 Oct 2026 10:50:15 +0530 Subject: [PATCH 12/26] 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 da4b9c352e22710ced2e727d137a3115add93cfa Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sun, 4 Oct 2026 12:59:03 +0530 Subject: [PATCH 13/26] 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) From 3ea7b3cea7825989323a086916181d5c8c0de6fd Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Tue, 6 Oct 2026 08:46:08 +0530 Subject: [PATCH 14/26] =?UTF-8?q?refactor(cbc):=20rename=20EntityContent/E?= =?UTF-8?q?ntityData=20=E2=86=92=20EntityData/ConfigEntity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EntityContent (the raw payload wrapper) → EntityData: names the thing it actually holds. EntityData (the entity container with entity_id + data) → ConfigEntity: scoped to the config layer, mirrors get_config_object naming, and removes ambiguity with the new EntityData. Also rename the two accessor methods for consistency: ConfigObject.get_entity → get_config_entity ConfigData.get_entity_data → get_config_entity Updated across _models.py, client.py, __init__.py, user-guide.md, and test_models.py. No behaviour change. --- src/sap_cloud_sdk/cbc/__init__.py | 6 ++-- src/sap_cloud_sdk/cbc/_models.py | 46 ++++++++++++++--------------- src/sap_cloud_sdk/cbc/client.py | 6 ++-- src/sap_cloud_sdk/cbc/user-guide.md | 16 +++++----- tests/cbc/unit/test_models.py | 32 ++++++++++---------- 5 files changed, 53 insertions(+), 53 deletions(-) diff --git a/src/sap_cloud_sdk/cbc/__init__.py b/src/sap_cloud_sdk/cbc/__init__.py index 849e17f9..a35d8047 100644 --- a/src/sap_cloud_sdk/cbc/__init__.py +++ b/src/sap_cloud_sdk/cbc/__init__.py @@ -20,7 +20,7 @@ # Access entity data payment = config.get_config_object("payment-config") if payment: - for row in payment.get_entity("payment-mode").data.as_list(): + for row in payment.get_config_entity("payment-mode").data.as_list(): print(row) Apps running on the SAP application platform can use @@ -60,10 +60,10 @@ from sap_cloud_sdk.cbc._models import ( ApiError, ConfigData, + ConfigEntity, ConfigObject, ConsumptionVersion, ConsumptionVersions, - EntityContent, EntityData, NNV, ) @@ -98,8 +98,8 @@ "ConsumptionVersions", "NNV", # models — entities - "EntityContent", "EntityData", + "ConfigEntity", "ConfigObject", # models — configuration "ConfigData", diff --git a/src/sap_cloud_sdk/cbc/_models.py b/src/sap_cloud_sdk/cbc/_models.py index d099d95b..daaee4e8 100644 --- a/src/sap_cloud_sdk/cbc/_models.py +++ b/src/sap_cloud_sdk/cbc/_models.py @@ -118,8 +118,8 @@ class ConfigObjectList(_FrozenModel): items: list[ConfigObjectEntry] -class EntityContent: - """Configuration content for an entity. +class EntityData: + """Configuration data for an entity. Wraps the raw API response data and enforces shape at access time. """ @@ -128,15 +128,15 @@ 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 ``True`` if the data 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 ``True`` if the data 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. + """Return the data 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. @@ -144,10 +144,10 @@ def value(self) -> list[dict[str, Any]] | dict[str, Any]: return self._raw def as_list(self) -> list[dict[str, Any]]: - """Return the content as a list of objects. + """Return the data as a list of objects. Raises: - ValueError: If the content is a dict, not a list. + ValueError: If the data is a dict, not a list. """ if not isinstance(self._raw, list): raise ValueError( @@ -156,10 +156,10 @@ def as_list(self) -> list[dict[str, Any]]: return self._raw def as_object(self) -> dict[str, Any]: - """Return the content as a dict. + """Return the data as a dict. Raises: - ValueError: If the content is a list, not a dict. + ValueError: If the data is a list, not a dict. """ if not isinstance(self._raw, dict): raise ValueError( @@ -168,20 +168,20 @@ def as_object(self) -> dict[str, Any]: return self._raw def __repr__(self) -> str: - return f"EntityContent({self._raw!r})" + return f"EntityData({self._raw!r})" @dataclass -class EntityData: - """Configuration content for a single entity. +class ConfigEntity: + """A single entity within a config object. Attributes: entity_id: Authored entity identifier (e.g. ``"payment-mode"``). - data: Configuration content for this entity. + data: Configuration data for this entity. """ entity_id: str - data: EntityContent + data: EntityData @dataclass @@ -194,16 +194,16 @@ class ConfigObject: """ config_object_id: str - entities: list[EntityData] + entities: list[ConfigEntity] - def get_entity(self, entity_id: str) -> EntityData | None: - """Return entity data for the given entity ID. + def get_config_entity(self, entity_id: str) -> ConfigEntity | None: + """Return the entity with the given entity ID. Args: entity_id: Authored entity identifier. Returns: - Matching :class:`EntityData`, or ``None`` if not found. + Matching :class:`ConfigEntity`, or ``None`` if not found. """ return next((e for e in self.entities if e.entity_id == entity_id), None) @@ -240,20 +240,20 @@ def get_config_object(self, config_object_id: str) -> ConfigObject | None: None, ) - def get_entity_data( + def get_config_entity( self, config_object_id: str, entity_id: str - ) -> EntityData | None: - """Return entity data for the given config object and entity. + ) -> ConfigEntity | None: + """Return the entity for the given config object and entity IDs. Args: config_object_id: Authored config object identifier. entity_id: Authored entity identifier. Returns: - Matching :class:`EntityData`, or ``None`` if not found. + Matching :class:`ConfigEntity`, 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 + return co.get_config_entity(entity_id) if co is not None else None # --------------------------------------------------------------------------- diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index 9d3376eb..5b5d534d 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -38,10 +38,10 @@ from sap_cloud_sdk.cbc._models import ( ApiError, ConfigData, + ConfigEntity, ConfigObject, ConfigObjectList, ConsumptionVersions, - EntityContent, EntityData, ) from sap_cloud_sdk.cbc.exceptions import ( @@ -339,7 +339,7 @@ def _fetch_entity_data( consumption_version: str, config_object_id: str, entity_id: str, - ) -> EntityData: + ) -> ConfigEntity: url = self._configurations_url( base_url, f"/consumptionVersions/{consumption_version}/configurationObjects" @@ -358,7 +358,7 @@ def _fetch_entity_data( raw_data = content.get("items", []) resolved_id = api_meta.get("entityName") or entity_id - return EntityData(entity_id=resolved_id, data=EntityContent(raw_data)) + return ConfigEntity(entity_id=resolved_id, data=EntityData(raw_data)) def _configurations_url(self, base_url: str, path: str = "") -> str: base = base_url.rstrip("/") diff --git a/src/sap_cloud_sdk/cbc/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index bc6f9623..7f39bf8e 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -69,16 +69,16 @@ ConfigData └── config_objects: list[ConfigObject] ├── ConfigObject │ ├── config_object_id: str # e.g. "payment-config" - │ └── entities: list[EntityData] - │ └── EntityData + │ └── entities: list[ConfigEntity] + │ └── ConfigEntity │ ├── entity_id: str # e.g. "payment-mode" - │ └── data: EntityContent # .as_list() or .as_object() + │ └── data: EntityData # .as_list() or .as_object() └── ConfigObject ├── config_object_id: str # e.g. "agent-config" - └── entities: list[EntityData] - └── EntityData + └── entities: list[ConfigEntity] + └── ConfigEntity ├── entity_id: str # e.g. "contact" - └── data: EntityContent # .as_list() or .as_object() + └── data: EntityData # .as_list() or .as_object() ``` ### Iterate all config objects and entities @@ -95,7 +95,7 @@ for co in config.config_objects: # 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 + modes = payment.get_config_entity("payment-mode") # ConfigEntity | None if modes: for row in modes.data.as_list(): print(row["paymentModeCode"], row["name"]) @@ -117,7 +117,7 @@ else: ```python # Shortcut — config object + entity in one step -modes = config.get_entity_data("payment-config", "payment-mode") # EntityData | None +modes = config.get_config_entity("payment-config", "payment-mode") # ConfigEntity | None ``` ```python diff --git a/tests/cbc/unit/test_models.py b/tests/cbc/unit/test_models.py index eb37624d..5c1c575c 100644 --- a/tests/cbc/unit/test_models.py +++ b/tests/cbc/unit/test_models.py @@ -12,7 +12,7 @@ ConfigObject, ConsumptionVersion, ConsumptionVersions, - EntityContent, + ConfigEntity, EntityData, ) @@ -72,42 +72,42 @@ def test_returns_last_item_when_no_dates(self): # --------------------------------------------------------------------------- -# EntityContent +# EntityData # --------------------------------------------------------------------------- -class TestEntityContent: +class TestEntityData: def test_as_list_returns_list(self): - ec = EntityContent([{"k": "v"}]) + ec = EntityData([{"k": "v"}]) assert ec.as_list() == [{"k": "v"}] def test_as_list_raises_when_dict(self): - ec = EntityContent({"k": "v"}) + ec = EntityData({"k": "v"}) with pytest.raises(ValueError, match="as_object"): ec.as_list() def test_as_object_returns_dict(self): - ec = EntityContent({"k": "v"}) + ec = EntityData({"k": "v"}) assert ec.as_object() == {"k": "v"} def test_as_object_raises_when_list(self): - ec = EntityContent([{"k": "v"}]) + ec = EntityData([{"k": "v"}]) with pytest.raises(ValueError, match="as_list"): ec.as_object() def test_is_list_and_is_object_for_list_content(self): - ec = EntityContent([{"k": "v"}]) + ec = EntityData([{"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"}) + ec = EntityData({"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"} + assert EntityData([{"k": "v"}]).value() == [{"k": "v"}] + assert EntityData({"k": "v"}).value() == {"k": "v"} # --------------------------------------------------------------------------- @@ -117,7 +117,7 @@ def test_value_returns_raw_without_asserting_shape(self): class TestConfigData: def _entity_data(self, entity_id: str) -> EntityData: - return EntityData(entity_id=entity_id, data=EntityContent([])) + return ConfigEntity(entity_id=entity_id, data=EntityData([])) def _config_object(self, config_object_id: str, *entity_ids: str) -> ConfigObject: return ConfigObject( @@ -145,17 +145,17 @@ 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): + def test_get_config_entity_returns_match(self): config = self._config( self._config_object("ObjA", "E1", "E2"), ) - result = config.get_entity_data("ObjA", "E2") + result = config.get_config_entity("ObjA", "E2") assert result is not None assert result.entity_id == "E2" - def test_get_entity_data_returns_none_when_missing(self): + def test_get_config_entity_returns_none_when_missing(self): config = self._config(self._config_object("ObjA", "E1")) - assert config.get_entity_data("ObjA", "Missing") is None + assert config.get_config_entity("ObjA", "Missing") is None # --------------------------------------------------------------------------- From d68fbc51236891d7c96e1f5766864311609a4055 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Tue, 6 Oct 2026 09:05:07 +0530 Subject: [PATCH 15/26] fix(cbc): align with confirmed API contract for entity data response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REST API team confirmed: - metadata.entityId is the correct field name (was entityName) - content.item / content.items struct is correct (already was) - ?appTenantId= query param is correct (already was) Since metadata.entityId equals the entity_id already known from the URL path, drop the metadata read entirely — entity_id is used directly. Remove the now-redundant resolved_id fallback and the deleted test_falls_back_to_path_entity_id_without_metadata test. Strip stale metadata fields from remaining mock response bodies. --- src/sap_cloud_sdk/cbc/client.py | 4 +--- tests/cbc/unit/test_client.py | 25 +------------------------ 2 files changed, 2 insertions(+), 27 deletions(-) diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index 5b5d534d..bb9ff7f7 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -348,7 +348,6 @@ def _fetch_entity_data( ) 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": @@ -357,8 +356,7 @@ def _fetch_entity_data( # ARRAY / UNSPECIFIED / absent — items may be absent or empty. raw_data = content.get("items", []) - resolved_id = api_meta.get("entityName") or entity_id - return ConfigEntity(entity_id=resolved_id, data=EntityData(raw_data)) + return ConfigEntity(entity_id=entity_id, data=EntityData(raw_data)) def _configurations_url(self, base_url: str, path: str = "") -> str: base = base_url.rstrip("/") diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index 9b036719..f25e0d24 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -186,10 +186,6 @@ def test_reads_array_content_items(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response( json_body={ - "metadata": { - "entityName": "tax-category", - "configurationObjectId": "tax-config", - }, "contentShape": "ARRAY", "content": {"items": [{"code": "STD"}], "adaptedKeys": []}, } @@ -208,10 +204,6 @@ def test_reads_object_content_item(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response( json_body={ - "metadata": { - "entityName": "globalSettings", - "configurationObjectId": "pmc-settings", - }, "contentShape": "OBJECT", "content": {"item": {"maxRetries": 3, "timeoutSeconds": 30}}, } @@ -229,7 +221,7 @@ def test_reads_object_content_item(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={"metadata": {"entityName": "policy"}, "contentShape": "ARRAY"} + json_body={"contentShape": "ARRAY"} ) result = client._fetch_entity_data( "https://cbc.example.ondemand.com", @@ -240,20 +232,6 @@ def test_defaults_to_empty_list_when_content_absent(self): ) assert result.data.as_list() == [] - def test_falls_back_to_path_entity_id_without_metadata(self): - client, mock_http = _make_client() - mock_http.request.return_value = _mock_response( - json_body={"contentShape": "ARRAY", "content": {"items": []}} - ) - result = client._fetch_entity_data( - "https://cbc.example.ondemand.com", - "app-tenant", - "cv1", - "tax-config", - "tax-rate", - ) - assert result.entity_id == "tax-rate" - # --------------------------------------------------------------------------- # DefaultClient — get_configuration @@ -292,7 +270,6 @@ def test_uses_explicit_consumption_version(self): ) data_response = _mock_response( json_body={ - "metadata": {"entityName": "payment-mode"}, "contentShape": "ARRAY", "content": {"items": [{"k": "v"}]}, } From efff5b86347d2e4321c7243acab3e3e0087886ee Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Tue, 6 Oct 2026 10:36:21 +0530 Subject: [PATCH 16/26] chore(cbc): fix ty annotation after ConfigEntity rename; bump version to 0.59.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix stale EntityData return annotation on TestConfigData._entity_data — missed during the EntityData → ConfigEntity rename. Also bump version to 0.59.0 (upstream released 0.58.0). --- tests/cbc/unit/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cbc/unit/test_models.py b/tests/cbc/unit/test_models.py index 5c1c575c..9b1e7243 100644 --- a/tests/cbc/unit/test_models.py +++ b/tests/cbc/unit/test_models.py @@ -116,7 +116,7 @@ def test_value_returns_raw_without_asserting_shape(self): class TestConfigData: - def _entity_data(self, entity_id: str) -> EntityData: + def _entity_data(self, entity_id: str) -> ConfigEntity: return ConfigEntity(entity_id=entity_id, data=EntityData([])) def _config_object(self, config_object_id: str, *entity_ids: str) -> ConfigObject: From eb241569d34275ca173ad0e333dc57fe76e2b91d Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Wed, 7 Oct 2026 16:45:38 +0530 Subject: [PATCH 17/26] feat(cbc): add get_entity_data for targeted single-entity fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes _fetch_entity_data as a public client method get_entity_data(), matching the Java SDK's getEntityData() for fetching one entity without pulling all configuration objects. Also renames ConfigObject/ConfigData .get_config_entity() → .get_entity_data() returning EntityData directly, removing the ConfigEntity wrapper the caller had to unwrap every time. --- src/sap_cloud_sdk/cbc/__init__.py | 2 +- src/sap_cloud_sdk/cbc/_models.py | 19 ++--- src/sap_cloud_sdk/cbc/client.py | 69 ++++++++++++++++++- src/sap_cloud_sdk/cbc/user-guide.md | 57 ++++++++++----- src/sap_cloud_sdk/core/telemetry/operation.py | 1 + tests/cbc/unit/test_client.py | 48 ++++++++++++- tests/cbc/unit/test_models.py | 6 +- tests/core/unit/telemetry/test_operation.py | 4 +- 8 files changed, 168 insertions(+), 38 deletions(-) diff --git a/src/sap_cloud_sdk/cbc/__init__.py b/src/sap_cloud_sdk/cbc/__init__.py index a35d8047..e6d52963 100644 --- a/src/sap_cloud_sdk/cbc/__init__.py +++ b/src/sap_cloud_sdk/cbc/__init__.py @@ -20,7 +20,7 @@ # Access entity data payment = config.get_config_object("payment-config") if payment: - for row in payment.get_config_entity("payment-mode").data.as_list(): + for row in payment.get_entity_data("payment-mode").as_list(): print(row) Apps running on the SAP application platform can use diff --git a/src/sap_cloud_sdk/cbc/_models.py b/src/sap_cloud_sdk/cbc/_models.py index daaee4e8..2fbe4264 100644 --- a/src/sap_cloud_sdk/cbc/_models.py +++ b/src/sap_cloud_sdk/cbc/_models.py @@ -196,16 +196,17 @@ class ConfigObject: config_object_id: str entities: list[ConfigEntity] - def get_config_entity(self, entity_id: str) -> ConfigEntity | None: - """Return the entity with the given entity ID. + def get_entity_data(self, entity_id: str) -> EntityData | None: + """Return the data for the entity with the given ID. Args: entity_id: Authored entity identifier. Returns: - Matching :class:`ConfigEntity`, or ``None`` if not found. + :class:`EntityData` for the matching entity, or ``None`` if not found. """ - return next((e for e in self.entities if e.entity_id == entity_id), None) + entity = next((e for e in self.entities if e.entity_id == entity_id), None) + return entity.data if entity is not None else None @dataclass @@ -240,20 +241,20 @@ def get_config_object(self, config_object_id: str) -> ConfigObject | None: None, ) - def get_config_entity( + def get_entity_data( self, config_object_id: str, entity_id: str - ) -> ConfigEntity | None: - """Return the entity for the given config object and entity IDs. + ) -> EntityData | None: + """Return the data for the given config object and entity IDs. Args: config_object_id: Authored config object identifier. entity_id: Authored entity identifier. Returns: - Matching :class:`ConfigEntity`, or ``None`` if not found. + :class:`EntityData` for the matching entity, or ``None`` if not found. """ co = self.get_config_object(config_object_id) - return co.get_config_entity(entity_id) if co is not None else None + return co.get_entity_data(entity_id) if co is not None else None # --------------------------------------------------------------------------- diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index bb9ff7f7..af9551ce 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -107,6 +107,19 @@ def get_configuration( """ ... + def get_entity_data( + self, + config_object_id: str, + entity_id: str, + consumption_version: str | None = None, + ) -> EntityData: + """Return the data for a single entity without fetching all configuration. + + When ``consumption_version`` is omitted, the latest version is resolved + automatically via :meth:`get_consumption_versions`. + """ + ... + # --------------------------------------------------------------------------- # Internal config dataclass @@ -138,6 +151,18 @@ class DefaultClient: Direct instantiation is supported for testing (inject a mock ``http_client``). + **Blocking I/O.** All public methods make synchronous HTTP calls via + ``httpx.Client``. In an async context, wrap calls with + ``asyncio.to_thread(client.get_configuration)`` to avoid blocking the event + loop. + + **Thread safety.** Safe to share a single instance across threads and async + tasks. The internal ``httpx.Client`` is thread-safe. The only mutable state + is the cached ``ssl.SSLContext`` — the app's own provider-level mTLS + certificate, which is deployment/landscape-specific and shared across all + tenants. It is rebuilt under a lock on TLS handshake failure — concurrent + rebuilds are safe and at most one rebuild runs at a time. + Args: base_url: Callable returning the CBC service base URL. Invoked on every request — in a multi-tenant agent the URL comes from a request-scoped @@ -328,9 +353,47 @@ def get_configuration( config_objects=config_objects, ) - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ + @record_metrics(Module.CBC, Operation.CBC_GET_ENTITY_DATA) + def get_entity_data( + self, + config_object_id: str, + entity_id: str, + consumption_version: str | None = None, + ) -> EntityData: + """Return the data for a single entity without fetching all configuration. + + Makes a targeted HTTP call for the one entity, avoiding the per-entity + calls that :meth:`get_configuration` makes for every entity in every + config object. + + Args: + config_object_id: Authored config object identifier (e.g. ``"payment-config"``). + entity_id: Authored entity identifier (e.g. ``"payment-mode"``). + consumption_version: Consumption version ID. When ``None``, the + latest version is resolved via :meth:`get_consumption_versions`. + + Returns: + :class:`EntityData` for the entity. + + Raises: + CBCClientError: On 4xx responses, or when no consumption version exists + for the tenant and ``consumption_version`` was not provided. + CBCServerError: On 5xx responses. + CBCNetworkError: On connection failures. + """ + base_url = self._base_url() + app_tenant_id = self._resolve_app_tenant_id() + if consumption_version is None: + versions = self._get_consumption_versions(base_url, app_tenant_id) + latest = versions.latest() + if latest is None: + raise CBCClientError( + f"CBC returned no consumption version for tenant={app_tenant_id!r}." + ) + consumption_version = latest.version + return self._fetch_entity_data( + base_url, app_tenant_id, consumption_version, config_object_id, entity_id + ).data def _fetch_entity_data( self, diff --git a/src/sap_cloud_sdk/cbc/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index 7f39bf8e..159d3978 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -92,38 +92,51 @@ for co in config.config_objects: ### 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_config_entity("payment-mode") # ConfigEntity | None - if modes: - for row in modes.data.as_list(): - print(row["paymentModeCode"], row["name"]) +# From already-fetched ConfigData +modes = config.get_entity_data("payment-config", "payment-mode") # EntityData | None +if modes: + for row in modes.as_list(): + print(row["paymentModeCode"], row["name"]) ``` -`modes.data.as_list()` returns `list[dict]` and raises `ValueError` if the data is not a list. -`modes.data.as_object()` returns `dict` and raises `ValueError` if the data is not a dict. +If you need all entities for one config object, use `get_config_object` to get the +`ConfigObject` and iterate its `entities` list. -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` +`modes.as_list()` returns `list[dict]` and raises `ValueError` if the data is not a list. +`modes.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.is_list()` / +`modes.is_object()`, or call `modes.value()` to get the raw `list[dict] | dict` without any shape assertion: ```python -if modes.data.is_list(): - rows = modes.data.as_list() +if modes.is_list(): + rows = modes.as_list() else: - settings = modes.data.as_object() + settings = modes.as_object() ``` ```python -# Shortcut — config object + entity in one step -modes = config.get_config_entity("payment-config", "payment-mode") # ConfigEntity | None +# Unmarshal into your own class +modes_list = [PaymentMode(**row) for row in modes.as_list()] +policy = PolicyConfig(**policy_entity.as_object()) ``` +### Fetch a single entity directly + +When you only need one entity, use `get_entity_data` to avoid fetching all +configuration objects and their entities: + ```python -# Unmarshal into your own class -modes_list = [PaymentMode(**row) for row in modes.data.as_list()] -policy = PolicyConfig(**policy_entity.data.as_object()) +# One targeted HTTP call — no full config fetch +modes = cbc_client.get_entity_data("payment-config", "payment-mode") +for row in modes.as_list(): + print(row["paymentModeCode"], row["name"]) + +# Pin a specific version +modes = cbc_client.get_entity_data( + "payment-config", "payment-mode", consumption_version="a0392d4f-..." +) ``` @@ -182,6 +195,12 @@ and picks the one whose `appTenantId` property matches; the `cbcUrl` is used verbatim. The certificate is the app's own provider-level mTLS certificate, fetched from the Destination Service. +**Performance note.** The fragment lookup (Destination Service HTTP call) runs +on every `get_configuration()` call, because the CBC URL is resolved fresh each +time via the `base_url` callable. For high-throughput agents making frequent CBC +calls, consider caching the resolved URL at the application layer (e.g. per +tenant, invalidated on `CBCClientError` with a not-found code). + **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 diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index 3df4d48d..4ae1893d 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -142,6 +142,7 @@ class Operation(str, Enum): # CBC Operations CBC_GET_CONSUMPTION_VERSIONS = "get_consumption_versions" CBC_GET_CONFIGURATION = "get_configuration" + CBC_GET_ENTITY_DATA = "get_entity_data" # Bootstrap Operations BOOTSTRAP = "bootstrap" diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index f25e0d24..b8de5a79 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -18,7 +18,7 @@ CBCNetworkError, CBCServerError, ) -from sap_cloud_sdk.cbc._models import ConfigData +from sap_cloud_sdk.cbc._models import ConfigData, EntityData # --------------------------------------------------------------------------- @@ -324,6 +324,52 @@ def base_url_fn() -> str: assert call_count == 1 + +# --------------------------------------------------------------------------- +# DefaultClient — get_entity_data +# --------------------------------------------------------------------------- + + +class TestGetEntityData: + def test_returns_entity_data_directly(self): + client, mock_http = _make_client() + versions_response = _mock_response(json_body={"items": [{"version": "v1"}]}) + entity_response = _mock_response( + json_body={ + "contentShape": "ARRAY", + "content": {"items": [{"code": "STD"}], "adaptedKeys": []}, + } + ) + mock_http.request.side_effect = [versions_response, entity_response] + + result = client.get_entity_data("payment-config", "payment-mode") + assert isinstance(result, EntityData) + assert result.as_list() == [{"code": "STD"}] + + def test_uses_pinned_version_without_resolving(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={ + "contentShape": "OBJECT", + "content": {"item": {"key": "value"}}, + } + ) + + result = client.get_entity_data( + "agent-config", "settings", consumption_version="v42" + ) + assert isinstance(result, EntityData) + assert result.as_object() == {"key": "value"} + # Only one HTTP call — no version resolution + assert mock_http.request.call_count == 1 + + def test_raises_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_entity_data("payment-config", "payment-mode") + + # --------------------------------------------------------------------------- # DefaultClient — context manager # --------------------------------------------------------------------------- diff --git a/tests/cbc/unit/test_models.py b/tests/cbc/unit/test_models.py index 9b1e7243..46450581 100644 --- a/tests/cbc/unit/test_models.py +++ b/tests/cbc/unit/test_models.py @@ -149,13 +149,13 @@ def test_get_config_entity_returns_match(self): config = self._config( self._config_object("ObjA", "E1", "E2"), ) - result = config.get_config_entity("ObjA", "E2") + result = config.get_entity_data("ObjA", "E2") assert result is not None - assert result.entity_id == "E2" + assert isinstance(result, EntityData) def test_get_config_entity_returns_none_when_missing(self): config = self._config(self._config_object("ObjA", "E1")) - assert config.get_config_entity("ObjA", "Missing") is None + assert config.get_entity_data("ObjA", "Missing") is None # --------------------------------------------------------------------------- diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index fe237ea8..cc42e94f 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -318,5 +318,5 @@ def test_operation_count(self): # 3 auditlog + 12 destination + 10 certificate + 10 fragment + 8 objectstore # + 2 extensibility + 7 aicore + 23 dms + 7 agentgateway + 13 agent_memory # + 5 data_anonymization + 52 adms + 6 print + 94 dpi_ng + 1 bootstrap + 3 output_management - # + 2 cbc = 258 - assert len(all_operations) == 258 + # + 3 cbc = 259 + assert len(all_operations) == 259 From 19c959c79115f061557787bb005878f732fa3238 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Wed, 7 Oct 2026 16:57:39 +0530 Subject: [PATCH 18/26] test(cbc): fix GUIDELINES violations found in code review - Add Args/Returns/Raises to all three CBCClient Protocol methods (get_consumption_versions, get_configuration, get_entity_data) - Rename all test methods across test_client.py, test_models.py, and test_client_adapter.py to the documented test___ pattern (pre-existing violations + 3 new from the prior commit) - Strengthen ConfigData.get_entity_data test: fixture now gives each entity distinct data so a wrong-entity selection bug would fail the assertion - Add BDD integration scenario for get_entity_data (CONTRIBUTING.md requires integration tests for new capabilities) --- src/sap_cloud_sdk/cbc/client.py | 36 +++++++++++++++++ tests/cbc/integration/cbc.feature | 6 +++ tests/cbc/integration/test_e2e_bdd.py | 32 ++++++++++++++- tests/cbc/unit/test_client.py | 56 +++++++++++++-------------- tests/cbc/unit/test_client_adapter.py | 36 ++++++++--------- tests/cbc/unit/test_models.py | 39 ++++++++++--------- 6 files changed, 139 insertions(+), 66 deletions(-) diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index af9551ce..d1d1aa8a 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -93,6 +93,14 @@ def get_consumption_versions(self) -> ConsumptionVersions: 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. + + Returns: + :class:`ConsumptionVersions` containing the list of available versions. + + Raises: + CBCClientError: On 4xx responses. + CBCServerError: On 5xx responses. + CBCNetworkError: On connection failures. """ ... @@ -104,6 +112,19 @@ def get_configuration( When ``consumption_version`` is omitted, the latest version is resolved automatically via :meth:`get_consumption_versions`. + + Args: + consumption_version: Consumption version ID. When ``None``, the + latest version is resolved automatically. + + Returns: + :class:`ConfigData` containing all config objects and their entities. + + 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. """ ... @@ -117,6 +138,21 @@ def get_entity_data( When ``consumption_version`` is omitted, the latest version is resolved automatically via :meth:`get_consumption_versions`. + + Args: + config_object_id: Authored config object identifier (e.g. ``"payment-config"``). + entity_id: Authored entity identifier (e.g. ``"payment-mode"``). + consumption_version: Consumption version ID. When ``None``, the + latest version is resolved automatically. + + Returns: + :class:`EntityData` for the entity. + + 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. """ ... diff --git a/tests/cbc/integration/cbc.feature b/tests/cbc/integration/cbc.feature index f3da6f35..6bb8154c 100644 --- a/tests/cbc/integration/cbc.feature +++ b/tests/cbc/integration/cbc.feature @@ -28,3 +28,9 @@ Feature: CBC (Central Business Configuration) Integration 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 + + # ── Single entity fetch ────────────────────────────────────────────────────── + + Scenario: Fetch a single entity directly returns EntityData + When I call get_entity_data for the first entity in the first config object + Then the result should be EntityData accessible as a list or object diff --git a/tests/cbc/integration/test_e2e_bdd.py b/tests/cbc/integration/test_e2e_bdd.py index a6fd4642..afcf0c4b 100644 --- a/tests/cbc/integration/test_e2e_bdd.py +++ b/tests/cbc/integration/test_e2e_bdd.py @@ -12,7 +12,7 @@ import pytest from pytest_bdd import given, scenario, then, when -from sap_cloud_sdk.cbc import ConfigData, ConsumptionVersions +from sap_cloud_sdk.cbc import ConfigData, ConsumptionVersions, EntityData from sap_cloud_sdk.cbc.client import DefaultClient pytestmark = pytest.mark.integration @@ -56,6 +56,14 @@ def test_every_entity_has_id_and_data(): pass +@scenario( + "cbc.feature", + "Fetch a single entity directly returns EntityData", +) +def test_get_entity_data_returns_entity_data(): + pass + + # -- Steps --------------------------------------------------------------------- @@ -129,3 +137,25 @@ def assert_entity_data_accessible(ctx: dict): except ValueError: result = raw.as_object() assert result is not None + + +@when("I call get_entity_data for the first entity in the first config object") +def call_get_entity_data(ctx: dict, cbc_client: DefaultClient): + config: ConfigData = cbc_client.get_configuration() + assert config.config_objects, "no config objects — cannot test get_entity_data" + co = config.config_objects[0] + assert co.entities, f"no entities in {co.config_object_id!r}" + entity_id = co.entities[0].entity_id + ctx["entity_data"] = cbc_client.get_entity_data(co.config_object_id, entity_id) + + +@then("the result should be EntityData accessible as a list or object") +def assert_single_entity_data(ctx: dict): + data: EntityData = ctx["entity_data"] + assert isinstance(data, EntityData) + try: + result = data.as_list() + assert result is not None + except ValueError: + result = data.as_object() + assert result is not None diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index b8de5a79..9a8b5ff1 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -64,7 +64,7 @@ def _make_client( class TestAppTenantIdCallable: - def test_callable_is_invoked_on_each_call(self): + def test_app_tenant_id_callable_on_each_call_invokes_callable(self): call_count = 0 def app_tenant_id_fn() -> str: @@ -92,7 +92,7 @@ def app_tenant_id_fn() -> str: class TestConfigurationsUrl: - def test_joins_base_url_and_path_verbatim(self): + def test_configurations_url_with_base_and_suffix_joins_correctly(self): client, _ = _make_client("https://my-tenant.cbc.example.ondemand.com") url = client._configurations_url( "https://my-tenant.cbc.example.ondemand.com", "/consumptionVersions" @@ -109,7 +109,7 @@ def test_joins_base_url_and_path_verbatim(self): class TestGetConsumptionVersions: - def test_returns_parsed_versions(self): + def test_get_consumption_versions_with_valid_response_returns_parsed_versions(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response( json_body={"items": [{"version": "cv1"}]} @@ -118,7 +118,7 @@ def test_returns_parsed_versions(self): assert len(result.items) == 1 assert result.items[0].version == "cv1" - def test_raises_client_error_on_404(self): + def test_get_consumption_versions_on_404_raises_cbc_client_error(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response( status_code=404, @@ -127,13 +127,13 @@ def test_raises_client_error_on_404(self): with pytest.raises(CBCClientError): client.get_consumption_versions() - def test_raises_server_error_on_500(self): + def test_get_consumption_versions_on_500_raises_cbc_server_error(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response(status_code=500, content=b"") with pytest.raises(CBCServerError): client.get_consumption_versions() - def test_raises_network_error_on_connection_failure(self): + def test_get_consumption_versions_on_connection_failure_raises_cbc_network_error(self): client, mock_http = _make_client() mock_http.request.side_effect = httpx.ConnectError("refused") with pytest.raises(CBCNetworkError): @@ -146,7 +146,7 @@ def test_raises_network_error_on_connection_failure(self): class TestGetConfigurationObjects: - def test_returns_grouped_config_objects(self): + def test_get_configuration_objects_with_multiple_objects_returns_grouped_config_objects(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response( json_body={ @@ -182,7 +182,7 @@ def test_returns_grouped_config_objects(self): class TestFetchEntityData: - def test_reads_array_content_items(self): + def test_fetch_entity_data_with_array_shape_returns_list_content(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response( json_body={ @@ -200,7 +200,7 @@ def test_reads_array_content_items(self): assert result.entity_id == "tax-category" assert result.data.as_list() == [{"code": "STD"}] - def test_reads_object_content_item(self): + def test_fetch_entity_data_with_object_shape_returns_dict_content(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response( json_body={ @@ -218,7 +218,7 @@ def test_reads_object_content_item(self): assert result.entity_id == "globalSettings" assert result.data.as_object() == {"maxRetries": 3, "timeoutSeconds": 30} - def test_defaults_to_empty_list_when_content_absent(self): + def test_fetch_entity_data_with_absent_content_defaults_to_empty_list(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response( json_body={"contentShape": "ARRAY"} @@ -239,7 +239,7 @@ def test_defaults_to_empty_list_when_content_absent(self): class TestGetConfiguration: - def test_resolves_latest_version_when_none_given(self): + def test_get_configuration_without_version_resolves_latest_and_returns_config_data(self): client, mock_http = _make_client() versions_response = _mock_response(json_body={"items": [{"version": "v2"}]}) config_objects_response = _mock_response(json_body={"items": []}) @@ -250,13 +250,13 @@ def test_resolves_latest_version_when_none_given(self): assert result.consumption_version == "v2" assert result.config_objects == [] - def test_raises_runtime_error_when_no_versions_exist(self): + def test_get_configuration_when_no_versions_exist_raises_cbc_client_error(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response(json_body={"items": []}) with pytest.raises(CBCClientError, match="no consumption version"): client.get_configuration() - def test_uses_explicit_consumption_version(self): + def test_get_configuration_with_explicit_version_skips_version_resolution(self): client, mock_http = _make_client() config_objects_response = _mock_response( json_body={ @@ -282,7 +282,7 @@ 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): + def test_get_configuration_with_multiple_entities_resolves_base_url_once(self): """One get_configuration resolves base_url once, not per HTTP request.""" call_count = 0 @@ -331,7 +331,7 @@ def base_url_fn() -> str: class TestGetEntityData: - def test_returns_entity_data_directly(self): + def test_get_entity_data_without_version_resolves_latest_and_returns_entity_data(self): client, mock_http = _make_client() versions_response = _mock_response(json_body={"items": [{"version": "v1"}]}) entity_response = _mock_response( @@ -346,7 +346,7 @@ def test_returns_entity_data_directly(self): assert isinstance(result, EntityData) assert result.as_list() == [{"code": "STD"}] - def test_uses_pinned_version_without_resolving(self): + def test_get_entity_data_with_pinned_version_skips_version_resolution(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response( json_body={ @@ -363,7 +363,7 @@ def test_uses_pinned_version_without_resolving(self): # Only one HTTP call — no version resolution assert mock_http.request.call_count == 1 - def test_raises_when_no_versions_exist(self): + def test_get_entity_data_when_no_versions_exist_raises_cbc_client_error(self): client, mock_http = _make_client() mock_http.request.return_value = _mock_response(json_body={"items": []}) with pytest.raises(CBCClientError, match="no consumption version"): @@ -376,7 +376,7 @@ def test_raises_when_no_versions_exist(self): class TestDefaultClientContextManager: - def test_close_called_on_exit(self): + def test_context_manager_on_exit_closes_http_client(self): client, mock_http = _make_client() with client: pass @@ -389,14 +389,14 @@ def test_close_called_on_exit(self): class TestCreateClient: - def test_returns_default_client(self): + def test_create_client_with_required_args_returns_default_client(self): client = create_client( base_url=lambda: "http://localhost:8001", app_tenant_id=lambda: "app-t1", ) assert isinstance(client, DefaultClient) - def test_passes_ssl_context_through(self): + def test_create_client_with_ssl_context_passes_it_through_to_default_client(self): import ssl ctx = ssl.create_default_context() @@ -427,13 +427,13 @@ def _tls_read_error() -> httpx.ReadError: class TestIsTlsFailure: - def test_true_for_read_error_wrapping_ssl_error(self): + def test_is_tls_failure_with_read_error_wrapping_ssl_error_returns_true(self): assert _is_tls_failure(_tls_read_error()) is True - def test_false_for_plain_connect_error(self): + def test_is_tls_failure_with_connect_error_returns_false(self): assert _is_tls_failure(httpx.ConnectError("connection refused")) is False - def test_false_for_non_ssl_os_error(self): + def test_is_tls_failure_with_non_ssl_os_error_returns_false(self): err = httpx.ReadError("read failed") err.__cause__ = OSError("broken pipe") assert _is_tls_failure(err) is False @@ -475,7 +475,7 @@ def build() -> httpx.Client: client._build_http_client = build # ty: ignore[invalid-assignment] return client, built - def test_reactive_success_rebuilds_and_retries(self): + def test_cert_rotation_on_tls_failure_rebuilds_client_and_retries_successfully(self): calls = {"factory": 0} def factory() -> ssl.SSLContext: @@ -502,7 +502,7 @@ def build_then_prime() -> httpx.Client: built[1].request.assert_called_once() assert result.items[0].version == "cv1" - def test_second_failure_propagates_after_one_rebuild(self): + def test_cert_rotation_on_second_tls_failure_after_rebuild_propagates_error(self): def factory() -> ssl.SSLContext: return ssl.create_default_context() @@ -524,7 +524,7 @@ def build_then_fail() -> httpx.Client: assert len(built) == 2 built[1].request.assert_called_once() - def test_non_tls_transport_error_does_not_rebuild(self): + def test_cert_rotation_on_non_tls_transport_error_does_not_rebuild_client(self): def factory() -> ssl.SSLContext: return ssl.create_default_context() @@ -537,7 +537,7 @@ def factory() -> ssl.SSLContext: # no rebuild — still only the initial mock assert len(built) == 1 - def test_no_factory_does_not_rebuild(self): + def test_cert_rotation_without_ssl_factory_does_not_rebuild_client(self): mock_http = MagicMock(spec=httpx.Client) mock_http.request.side_effect = _tls_read_error() client = DefaultClient( @@ -551,7 +551,7 @@ def test_no_factory_does_not_rebuild(self): # one attempt only, no rebuild path mock_http.request.assert_called_once() - def test_rebuild_failure_propagates_and_keeps_client(self): + def test_cert_rotation_when_factory_fails_propagates_error_and_retains_old_client(self): calls = {"factory": 0} def factory() -> ssl.SSLContext: diff --git a/tests/cbc/unit/test_client_adapter.py b/tests/cbc/unit/test_client_adapter.py index afe6e02e..6df3a836 100644 --- a/tests/cbc/unit/test_client_adapter.py +++ b/tests/cbc/unit/test_client_adapter.py @@ -37,11 +37,11 @@ def _reset_contextvars(): class TestResolveAppTenantId: - def test_returns_contextvar_value(self): + def test_resolve_app_tenant_id_with_set_contextvar_returns_value(self): app_tenant_id_var.set("app-t1") assert resolve_app_tenant_id() == "app-t1" - def test_raises_when_empty(self): + def test_resolve_app_tenant_id_with_empty_contextvar_raises_cbc_config_error(self): with pytest.raises(CBCConfigError, match="cbc_app_tenant_id"): resolve_app_tenant_id() @@ -52,7 +52,7 @@ def test_raises_when_empty(self): class TestResolveBaseUrl: - def test_reads_cbc_url_from_matching_fragment(self): + def test_resolve_base_url_with_matching_fragment_returns_cbc_url(self): tenant_subdomain_var.set("appfnd-subscriber") app_tenant_id_var.set("app-t1") other = MagicMock() @@ -78,17 +78,17 @@ def test_reads_cbc_url_from_matching_fragment(self): tenant="appfnd-subscriber" ) - def test_raises_when_subdomain_empty(self): + def test_resolve_base_url_with_empty_subdomain_raises_cbc_config_error(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): + def test_resolve_base_url_with_empty_app_tenant_id_raises_cbc_config_error(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): + def test_resolve_base_url_with_no_matching_fragment_raises_cbc_config_error(self): tenant_subdomain_var.set("appfnd-subscriber") app_tenant_id_var.set("app-t1") other = MagicMock() @@ -103,7 +103,7 @@ def test_raises_when_no_matching_fragment(self): with pytest.raises(CBCConfigError, match="No CBC mapping fragment"): resolve_base_url("default") - def test_raises_when_cbc_url_missing_from_fragment(self): + def test_resolve_base_url_with_fragment_missing_cbc_url_raises_cbc_config_error(self): tenant_subdomain_var.set("appfnd-subscriber") app_tenant_id_var.set("app-t1") match = MagicMock() @@ -118,7 +118,7 @@ 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): + def test_resolve_base_url_when_destination_raises_wraps_as_cbc_config_error(self): from sap_cloud_sdk.destination.exceptions import DestinationOperationError tenant_subdomain_var.set("appfnd-subscriber") @@ -141,7 +141,7 @@ def test_wraps_destination_error_as_config_error(self): class TestLoadSslContext: - def test_loads_cert_into_ssl_context(self): + def test_load_ssl_context_with_valid_cert_returns_ssl_context(self): ctx = ssl.create_default_context() cert = MagicMock() cert.content = "" @@ -164,7 +164,7 @@ def test_loads_cert_into_ssl_context(self): assert result is ctx load_pem.assert_called_once_with("", b"secret", "my-cert.pem") - def test_raises_when_cert_not_found(self): + def test_load_ssl_context_when_cert_not_found_raises_cbc_config_error(self): fake_cert_client = MagicMock() fake_cert_client.get_subaccount_certificate.return_value = None with patch( @@ -174,7 +174,7 @@ 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): + def test_load_ssl_context_when_destination_raises_wraps_as_cbc_config_error(self): from sap_cloud_sdk.destination.exceptions import DestinationOperationError fake_cert_client = MagicMock() @@ -197,7 +197,7 @@ def test_wraps_destination_error_as_config_error(self): class TestCreateAgentClient: - def test_builds_ssl_context_from_cert_default(self, monkeypatch): + def test_create_agent_client_with_landscape_env_builds_ssl_context_from_cert(self, monkeypatch): monkeypatch.setenv(ENV_LANDSCAPE, "cbc-fndtst-dev-eu12") with patch( "sap_cloud_sdk.cbc.client_adapter.load_ssl_context", @@ -216,7 +216,7 @@ def test_builds_ssl_context_from_cert_default(self, monkeypatch): client._ssl_factory() assert load.call_count == 2 - def test_env_overrides_apply(self, monkeypatch): + def test_create_agent_client_with_env_overrides_applies_instance_and_cert_name(self, monkeypatch): monkeypatch.setenv(ENV_DESTINATION_INSTANCE, "cbc-instance") monkeypatch.setenv(ENV_CERT_NAME, "my-cert.pem") with patch( @@ -228,7 +228,7 @@ def test_env_overrides_apply(self, monkeypatch): assert instance_arg == "cbc-instance" assert cert_arg == "my-cert.pem" - def test_config_values_apply(self): + def test_create_agent_client_with_config_object_applies_instance_and_cert_name(self): with patch( "sap_cloud_sdk.cbc.client_adapter.load_ssl_context", return_value=ssl.create_default_context(), @@ -243,7 +243,7 @@ def test_config_values_apply(self): assert instance_arg == "cbc-instance" assert cert_arg == "my-cert.pem" - def test_config_value_wins_over_env(self, monkeypatch): + def test_create_agent_client_with_config_and_env_set_config_value_wins(self, monkeypatch): monkeypatch.setenv(ENV_DESTINATION_INSTANCE, "from-env") monkeypatch.setenv(ENV_CERT_NAME, "from-env.pem") with patch( @@ -260,13 +260,13 @@ def test_config_value_wins_over_env(self, monkeypatch): assert instance_arg == "from-config" assert cert_arg == "from-config.pem" - def test_raises_when_landscape_unset_and_no_cert_name(self, monkeypatch): + def test_create_agent_client_without_landscape_or_cert_name_raises_cbc_config_error(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): + def test_create_agent_client_with_contextvars_set_wires_resolvers_into_client(self, monkeypatch): """The two ContextVars drive base_url + app_tenant_id at request time.""" monkeypatch.setenv(ENV_CERT_NAME, "my-cert.pem") with patch( @@ -300,7 +300,7 @@ def test_wires_resolvers_into_client(self, monkeypatch): class TestComposeWithPublicResolvers: - def test_compose_core_with_public_resolvers(self): + def test_compose_core_with_public_resolvers_builds_valid_default_client(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( diff --git a/tests/cbc/unit/test_models.py b/tests/cbc/unit/test_models.py index 46450581..bfbb746e 100644 --- a/tests/cbc/unit/test_models.py +++ b/tests/cbc/unit/test_models.py @@ -35,10 +35,10 @@ def _version( createdDate=created, ) - def test_returns_none_for_empty_list(self): + def test_latest_with_empty_list_returns_none(self): assert ConsumptionVersions(items=[]).latest() is None - def test_returns_latest_by_modified_date(self): + def test_latest_with_modified_dates_returns_most_recently_modified(self): t1 = datetime(2024, 1, 1, tzinfo=timezone.utc) t2 = datetime(2024, 6, 1, tzinfo=timezone.utc) v = ConsumptionVersions( @@ -51,7 +51,7 @@ def test_returns_latest_by_modified_date(self): assert result is not None assert result.version == "v2" - def test_returns_latest_by_created_date_when_no_modified(self): + def test_latest_without_modified_dates_returns_most_recently_created(self): t1 = datetime(2024, 1, 1, tzinfo=timezone.utc) t2 = datetime(2024, 6, 1, tzinfo=timezone.utc) v = ConsumptionVersions( @@ -64,7 +64,7 @@ def test_returns_latest_by_created_date_when_no_modified(self): assert result is not None assert result.version == "v2" - def test_returns_last_item_when_no_dates(self): + def test_latest_without_any_dates_returns_last_item(self): v = ConsumptionVersions(items=[self._version("v1"), self._version("v2")]) result = v.latest() assert result is not None @@ -77,35 +77,35 @@ def test_returns_last_item_when_no_dates(self): class TestEntityData: - def test_as_list_returns_list(self): + def test_as_list_with_list_content_returns_list(self): ec = EntityData([{"k": "v"}]) assert ec.as_list() == [{"k": "v"}] - def test_as_list_raises_when_dict(self): + def test_as_list_with_dict_content_raises_value_error(self): ec = EntityData({"k": "v"}) with pytest.raises(ValueError, match="as_object"): ec.as_list() - def test_as_object_returns_dict(self): + def test_as_object_with_dict_content_returns_dict(self): ec = EntityData({"k": "v"}) assert ec.as_object() == {"k": "v"} - def test_as_object_raises_when_list(self): + def test_as_object_with_list_content_raises_value_error(self): ec = EntityData([{"k": "v"}]) with pytest.raises(ValueError, match="as_list"): ec.as_object() - def test_is_list_and_is_object_for_list_content(self): + def test_is_list_with_list_content_returns_true_and_is_object_returns_false(self): ec = EntityData([{"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): + def test_is_object_with_dict_content_returns_true_and_is_list_returns_false(self): ec = EntityData({"k": "v"}) assert ec.is_object() is True assert ec.is_list() is False - def test_value_returns_raw_without_asserting_shape(self): + def test_value_with_any_content_returns_raw_without_shape_assertion(self): assert EntityData([{"k": "v"}]).value() == [{"k": "v"}] assert EntityData({"k": "v"}).value() == {"k": "v"} @@ -117,7 +117,7 @@ def test_value_returns_raw_without_asserting_shape(self): class TestConfigData: def _entity_data(self, entity_id: str) -> ConfigEntity: - return ConfigEntity(entity_id=entity_id, data=EntityData([])) + return ConfigEntity(entity_id=entity_id, data=EntityData([{"id": entity_id}])) def _config_object(self, config_object_id: str, *entity_ids: str) -> ConfigObject: return ConfigObject( @@ -132,7 +132,7 @@ def _config(self, *config_objects: ConfigObject) -> ConfigData: config_objects=list(config_objects), ) - def test_get_config_object_returns_matching(self): + def test_get_config_object_with_matching_id_returns_config_object(self): config = self._config( self._config_object("ObjA", "E1"), self._config_object("ObjB", "E2"), @@ -141,19 +141,20 @@ def test_get_config_object_returns_matching(self): assert result is not None assert result.config_object_id == "ObjA" - def test_get_config_object_returns_none_when_missing(self): + def test_get_config_object_with_missing_id_returns_none(self): config = self._config(self._config_object("ObjA", "E1")) assert config.get_config_object("Missing") is None - def test_get_config_entity_returns_match(self): + def test_get_entity_data_with_matching_ids_returns_correct_entity_data(self): config = self._config( self._config_object("ObjA", "E1", "E2"), ) result = config.get_entity_data("ObjA", "E2") assert result is not None assert isinstance(result, EntityData) + assert result.as_list() == [{"id": "E2"}] - def test_get_config_entity_returns_none_when_missing(self): + def test_get_entity_data_with_missing_entity_id_returns_none(self): config = self._config(self._config_object("ObjA", "E1")) assert config.get_entity_data("ObjA", "Missing") is None @@ -164,17 +165,17 @@ def test_get_config_entity_returns_none_when_missing(self): class TestApiError: - def test_parses_cbc_error_envelope(self): + def test_from_response_with_cbc_error_envelope_parses_code_and_message(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): + def test_from_response_with_empty_body_falls_back_to_unknown_error(self): err = ApiError.from_response(None) assert err.code == "UNKNOWN_ERROR" - def test_fallback_on_unparseable_body(self): + def test_from_response_with_unparseable_body_falls_back_to_unknown_error(self): err = ApiError.from_response(b"not json") assert err.code == "UNKNOWN_ERROR" assert "not json" in err.message From 0542e12ac692dadb7812f8dfd9d399abd0c13448 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Wed, 7 Oct 2026 17:13:55 +0530 Subject: [PATCH 19/26] feat(cbc): adapt resolve_base_url to direct fragment lookup after PR #82 Fragment naming in the platform changed from CBC_TenantMapping_ to CBC_TenantMapping_ (sap-internal-sdk-python#82), enabling a direct get_subaccount_fragment lookup by subdomain instead of listing all fragments and filtering by appTenantId. Update resolve_base_url to use the single direct lookup, and update all five TestResolveBaseUrl unit tests to mock get_subaccount_fragment accordingly. --- src/sap_cloud_sdk/cbc/client_adapter.py | 41 ++++++++++--------------- tests/cbc/unit/test_client_adapter.py | 31 +++++++++---------- 2 files changed, 31 insertions(+), 41 deletions(-) diff --git a/src/sap_cloud_sdk/cbc/client_adapter.py b/src/sap_cloud_sdk/cbc/client_adapter.py index f1b533ed..dc6f6cec 100644 --- a/src/sap_cloud_sdk/cbc/client_adapter.py +++ b/src/sap_cloud_sdk/cbc/client_adapter.py @@ -156,14 +156,10 @@ def resolve_base_url(destination_instance: str) -> str: :func:`~sap_cloud_sdk.cbc.client.create_client` when overriding only *some* of the platform defaults. - Reads :data:`tenant_subdomain_var` and :data:`app_tenant_id_var`, lists the - tenant-mapping Fragments in the subscriber's subaccount, finds the one whose - ``appTenantId`` property matches, and returns its ``cbcUrl`` verbatim. - - The fragments are named ``CBC_TenantMapping_``, so they cannot be - fetched directly by subdomain; the subaccount is listed and matched on the - ``appTenantId`` property instead. (Once the fragment is keyed by subdomain - upstream, this becomes a single direct ``get_subaccount_fragment`` lookup.) + Reads :data:`tenant_subdomain_var` and :data:`app_tenant_id_var`, fetches + the tenant-mapping Fragment for the subscriber's subdomain directly by name + (``CBC_TenantMapping_``), and returns its ``cbcUrl`` property + verbatim. Args: destination_instance: The ``instance`` passed to the destination @@ -174,7 +170,7 @@ def resolve_base_url(destination_instance: str) -> str: CBCConfigError: If either ContextVar is empty, no matching fragment is found, or the matched fragment has no ``cbcUrl`` property. """ - from sap_cloud_sdk.destination import create_fragment_client + from sap_cloud_sdk.destination import AccessStrategy, create_fragment_client from sap_cloud_sdk.destination.exceptions import DestinationError subdomain = tenant_subdomain_var.get() @@ -185,34 +181,31 @@ def resolve_base_url(destination_instance: str) -> str: ) app_tenant_id = resolve_app_tenant_id() + fragment_name = f"{CBC_FRAGMENT_PREFIX}{subdomain}" try: client = create_fragment_client(instance=destination_instance) - fragments = client.list_subaccount_fragments(tenant=subdomain) + fragment = client.get_subaccount_fragment( + fragment_name, + access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + 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}" + f"Could not resolve the CBC URL: fetching tenant-mapping fragment " + f"{fragment_name!r} failed for subdomain={subdomain!r} " + f"(appTenantId={app_tenant_id!r}): {exc}" ) from exc - fragment = next( - ( - f - for f in fragments - if f.name.startswith(CBC_FRAGMENT_PREFIX) - and f.properties.get("appTenantId") == app_tenant_id - ), - None, - ) if fragment is None: raise CBCConfigError( - f"No CBC mapping fragment for appTenantId={app_tenant_id!r} in " - f"subdomain={subdomain!r} (looked for {CBC_FRAGMENT_PREFIX}* fragments)." + f"No CBC mapping fragment for subdomain={subdomain!r} " + f"(appTenantId={app_tenant_id!r}): {fragment_name!r} not found." ) 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 + return cbc_url def load_ssl_context( diff --git a/tests/cbc/unit/test_client_adapter.py b/tests/cbc/unit/test_client_adapter.py index 6df3a836..8ebcbe83 100644 --- a/tests/cbc/unit/test_client_adapter.py +++ b/tests/cbc/unit/test_client_adapter.py @@ -22,6 +22,7 @@ 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 +from sap_cloud_sdk.destination import AccessStrategy @pytest.fixture(autouse=True) @@ -55,17 +56,14 @@ class TestResolveBaseUrl: def test_resolve_base_url_with_matching_fragment_returns_cbc_url(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.name = f"{CBC_FRAGMENT_PREFIX}appfnd-subscriber" match.properties = { "appTenantId": "app-t1", "cbcUrl": "https://cbc.example.cloud.sap", } fake_client = MagicMock() - fake_client.list_subaccount_fragments.return_value = [other, match] + fake_client.get_subaccount_fragment.return_value = match with patch( "sap_cloud_sdk.destination.create_fragment_client", @@ -74,8 +72,10 @@ def test_resolve_base_url_with_matching_fragment_returns_cbc_url(self): url = resolve_base_url("default") assert url == "https://cbc.example.cloud.sap" - fake_client.list_subaccount_fragments.assert_called_once_with( - tenant="appfnd-subscriber" + fake_client.get_subaccount_fragment.assert_called_once_with( + f"{CBC_FRAGMENT_PREFIX}appfnd-subscriber", + access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + tenant="appfnd-subscriber", ) def test_resolve_base_url_with_empty_subdomain_raises_cbc_config_error(self): @@ -91,11 +91,8 @@ def test_resolve_base_url_with_empty_app_tenant_id_raises_cbc_config_error(self) def test_resolve_base_url_with_no_matching_fragment_raises_cbc_config_error(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] + fake_client.get_subaccount_fragment.return_value = None with patch( "sap_cloud_sdk.destination.create_fragment_client", return_value=fake_client, @@ -107,10 +104,10 @@ def test_resolve_base_url_with_fragment_missing_cbc_url_raises_cbc_config_error( tenant_subdomain_var.set("appfnd-subscriber") app_tenant_id_var.set("app-t1") match = MagicMock() - match.name = f"{CBC_FRAGMENT_PREFIX}cbc-t1" + match.name = f"{CBC_FRAGMENT_PREFIX}appfnd-subscriber" match.properties = {"appTenantId": "app-t1"} fake_client = MagicMock() - fake_client.list_subaccount_fragments.return_value = [match] + fake_client.get_subaccount_fragment.return_value = match with patch( "sap_cloud_sdk.destination.create_fragment_client", return_value=fake_client, @@ -124,8 +121,8 @@ def test_resolve_base_url_when_destination_raises_wraps_as_cbc_config_error(self 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" + fake_client.get_subaccount_fragment.side_effect = DestinationOperationError( + "failed to get fragment: token error" ) with patch( "sap_cloud_sdk.destination.create_fragment_client", @@ -281,13 +278,13 @@ def test_create_agent_client_with_contextvars_set_wires_resolvers_into_client(se tenant_subdomain_var.set("appfnd-subscriber") fragment = MagicMock() - fragment.name = f"{CBC_FRAGMENT_PREFIX}cbc-t1" + fragment.name = f"{CBC_FRAGMENT_PREFIX}appfnd-subscriber" fragment.properties = { "appTenantId": "app-t1", "cbcUrl": "https://cbc.example.cloud.sap", } fake_fc = MagicMock() - fake_fc.list_subaccount_fragments.return_value = [fragment] + fake_fc.get_subaccount_fragment.return_value = fragment with patch( "sap_cloud_sdk.destination.create_fragment_client", return_value=fake_fc ): From 1bacd8bde5975f22f4e5080b6b148332390e7460 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Wed, 14 Oct 2026 19:45:47 +0530 Subject: [PATCH 20/26] refactor(cbc): collapse to single create_client mirroring agentgateway Reshape the public surface to match the agentgateway module, per PR #333 review feedback: - single create_client(config: CBCConfig | None = None) -> CBCClient; with no config it infers everything from provisioning, a CBCConfig overrides individual pieces - one concrete CBCClient (drop the CBCClient Protocol / DefaultClient split) - remove create_agent_client and the client_adapter.py layer - read tenant identity from the shared sap_cloud_sdk.core.runtime_context (APP_TENANT_ID, DWC_SUBDOMAIN) instead of module-local ContextVars - CBC URL + mTLS cert resolved via sap_cloud_sdk.destination (tenant-mapping Fragment + Destination Service), same primitives agentgateway uses - default mTLS loader applies only in pure provisioning mode; overriding base_url or app_tenant_id leaves ssl_context unset => plain HTTPS uv.lock is regenerated to sync sap-cloud-sdk to 0.59.0 (matching pyproject.toml) and realign the lockfile revision to origin/main (3). --- src/sap_cloud_sdk/cbc/__init__.py | 45 +- src/sap_cloud_sdk/cbc/client.py | 425 +++++++++++++----- src/sap_cloud_sdk/cbc/client_adapter.py | 312 ------------- src/sap_cloud_sdk/cbc/config.py | 31 +- src/sap_cloud_sdk/cbc/user-guide.md | 133 +++--- tests/cbc/integration/conftest.py | 10 +- tests/cbc/integration/test_e2e_bdd.py | 10 +- tests/cbc/unit/test_client.py | 85 ++-- ...lient_adapter.py => test_create_client.py} | 168 ++++--- uv.lock | 10 +- 10 files changed, 556 insertions(+), 673 deletions(-) delete mode 100644 src/sap_cloud_sdk/cbc/client_adapter.py rename tests/cbc/unit/{test_client_adapter.py => test_create_client.py} (63%) diff --git a/src/sap_cloud_sdk/cbc/__init__.py b/src/sap_cloud_sdk/cbc/__init__.py index e6d52963..0d098a74 100644 --- a/src/sap_cloud_sdk/cbc/__init__.py +++ b/src/sap_cloud_sdk/cbc/__init__.py @@ -10,11 +10,11 @@ from sap_cloud_sdk import cbc - cbc_client = cbc.create_client( - base_url=lambda: resolve_cbc_url(), - app_tenant_id=lambda: resolve_app_tenant_id(), - ssl_context=lambda: build_ssl_ctx(), - ) + # Infers everything from provisioning: the app tenant id and subdomain come + # from the SDK runtime context (wire bootstrap(app) once at startup), the CBC + # URL from the tenant-mapping Destination Fragment, and the mTLS certificate + # from the Destination Service. + cbc_client = cbc.create_client() config = cbc_client.get_configuration() # Access entity data @@ -23,30 +23,18 @@ for row in payment.get_entity_data("payment-mode").as_list(): print(row) -Apps running on the SAP application platform can use -:func:`~sap_cloud_sdk.cbc.client_adapter.create_agent_client` instead, which supplies the -two resolvers and the mTLS context from the platform's provisioning conventions -— see the CBC user guide. +To override any individual piece (e.g. for local development or tests), pass a +:class:`~sap_cloud_sdk.cbc.config.CBCConfig` — see the CBC user guide. """ from __future__ import annotations from sap_cloud_sdk.cbc.client import ( CBCClient, - DefaultClient, create_client, ) -from sap_cloud_sdk.cbc.client_adapter import ( - CBC_FRAGMENT_PREFIX, - app_tenant_id_var, - create_agent_client, - load_ssl_context, - resolve_app_tenant_id, - resolve_base_url, - tenant_subdomain_var, -) from sap_cloud_sdk.cbc.config import ( - CBCDestinationConfig, + CBCConfig, ) from sap_cloud_sdk.cbc.exceptions import ( CBCError, @@ -70,21 +58,12 @@ __all__ = [ - # factories + # factory "create_client", - "create_agent_client", - # clients + # client "CBCClient", - "DefaultClient", - # platform adapter - "app_tenant_id_var", - "tenant_subdomain_var", - "CBC_FRAGMENT_PREFIX", - "CBCDestinationConfig", - # platform resolvers (compose with create_client to override one axis) - "resolve_base_url", - "resolve_app_tenant_id", - "load_ssl_context", + # configuration + "CBCConfig", # exceptions "CBCError", "CBCClientError", diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index d1d1aa8a..96c524b0 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -1,37 +1,38 @@ -"""CBC client implementations for reading business configuration from CBC. +"""CBC client for reading business configuration from CBC. This module provides: -- :class:`CBCClient` — Protocol defining the client interface; use for type - annotations and test doubles. -- :class:`DefaultClient` — Production client using mTLS against the CBC service. -- :func:`create_client` — Thin factory over :class:`DefaultClient`. +- :class:`CBCClient` — the CBC client; reads tenant-specific business + configuration from the CBC service over mTLS. +- :func:`create_client` — the factory. Call it with no arguments to infer + everything from the application-platform provisioning conventions, or pass a + :class:`~sap_cloud_sdk.cbc.config.CBCConfig` to override individual pieces. -``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. +By default the client derives, per request, the CBC URL and the application +tenant id from the SDK-wide runtime context +(:mod:`sap_cloud_sdk.core.runtime_context`, populated once at startup by +``bootstrap(app)``), and loads the app's provider-level mTLS certificate from +the Destination Service. The certificate is reloaded automatically on a TLS +handshake failure, so a long-lived client recovers from certificate rotation +without being recreated. Quick start:: from sap_cloud_sdk import cbc - cbc_client = cbc.create_client( - base_url=lambda: resolve_cbc_url(), - app_tenant_id=lambda: resolve_app_tenant_id(), - ssl_context=lambda: build_ssl_ctx(), - ) + cbc_client = cbc.create_client() # infer everything from provisioning config = cbc_client.get_configuration() """ from __future__ import annotations import logging +import os import ssl import threading from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Protocol +from typing import Any import httpx @@ -44,17 +45,57 @@ ConsumptionVersions, EntityData, ) +from sap_cloud_sdk.cbc.config import CBCConfig from sap_cloud_sdk.cbc.exceptions import ( CBCClientError, + CBCConfigError, CBCNetworkError, CBCServerError, HttpContext, ) +from sap_cloud_sdk.core.runtime_context import ( + APP_TENANT_ID, + DWC_SUBDOMAIN, + get_context, +) from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Platform provisioning conventions +# --------------------------------------------------------------------------- +# The fragment prefix and certificate name below follow the application-platform +# convention; they are not arbitrary. Override via CBCConfig / 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 _is_tls_failure(exc: BaseException) -> bool: """Return ``True`` if an :class:`ssl.SSLError` appears in the cause chain. @@ -75,88 +116,6 @@ def _is_tls_failure(exc: BaseException) -> bool: return False -# --------------------------------------------------------------------------- -# Public Protocol (interface for type annotations and test doubles) -# --------------------------------------------------------------------------- - - -class CBCClient(Protocol): - """Interface for reading business configuration from CBC. - - Implement this Protocol to substitute :class:`DefaultClient` with a test - double, offline stub, or alternative production client. - """ - - def get_consumption_versions(self) -> ConsumptionVersions: - """Return the available consumption versions for the configured tenant. - - A consumption version represents a snapshot of the business configuration - for an app tenant at a point in time. Use this to discover the active - version ID when you don't already have it. - - Returns: - :class:`ConsumptionVersions` containing the list of available versions. - - Raises: - CBCClientError: On 4xx responses. - CBCServerError: On 5xx responses. - CBCNetworkError: On connection failures. - """ - ... - - def get_configuration( - self, - consumption_version: str | None = None, - ) -> ConfigData: - """Return the business configuration for all entities in one call. - - When ``consumption_version`` is omitted, the latest version is resolved - automatically via :meth:`get_consumption_versions`. - - Args: - consumption_version: Consumption version ID. When ``None``, the - latest version is resolved automatically. - - Returns: - :class:`ConfigData` containing all config objects and their entities. - - 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. - """ - ... - - def get_entity_data( - self, - config_object_id: str, - entity_id: str, - consumption_version: str | None = None, - ) -> EntityData: - """Return the data for a single entity without fetching all configuration. - - When ``consumption_version`` is omitted, the latest version is resolved - automatically via :meth:`get_consumption_versions`. - - Args: - config_object_id: Authored config object identifier (e.g. ``"payment-config"``). - entity_id: Authored entity identifier (e.g. ``"payment-mode"``). - consumption_version: Consumption version ID. When ``None``, the - latest version is resolved automatically. - - Returns: - :class:`EntityData` for the entity. - - 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. - """ - ... - - # --------------------------------------------------------------------------- # Internal config dataclass # --------------------------------------------------------------------------- @@ -164,26 +123,22 @@ def get_entity_data( @dataclass(frozen=True) class _ApiPaths: - """API path configuration for a :class:`DefaultClient` instance.""" + """API path configuration for a :class:`CBCClient` instance.""" configurations_path: str # --------------------------------------------------------------------------- -# DefaultClient +# CBCClient # --------------------------------------------------------------------------- -class DefaultClient: - """CBC client implementation. +class CBCClient: + """Client for reading business configuration from CBC. Prefer :func:`create_client` over direct instantiation:: - cbc_client = cbc.create_client( - base_url=lambda: resolve_cbc_url(), - app_tenant_id=lambda: resolve_app_tenant_id(), - ssl_context=lambda: build_ssl_ctx(), - ) + cbc_client = cbc.create_client() Direct instantiation is supported for testing (inject a mock ``http_client``). @@ -245,7 +200,7 @@ def close(self) -> None: """Close the underlying HTTP client and release connections.""" self._http_client.close() - def __enter__(self) -> "DefaultClient": + def __enter__(self) -> "CBCClient": return self def __exit__(self, *args: Any) -> None: @@ -262,6 +217,10 @@ def _resolve_app_tenant_id(self) -> str: def get_consumption_versions(self) -> ConsumptionVersions: """Return available consumption versions for the configured tenant. + A consumption version represents a snapshot of the business configuration + for an app tenant at a point in time. Use this to discover the active + version ID when you don't already have it. + Returns: :class:`ConsumptionVersions` with all versions for the tenant. @@ -544,32 +503,252 @@ def _network_error(self, method: str, url: str, exc: Exception) -> CBCNetworkErr ) +# --------------------------------------------------------------------------- +# Default platform resolvers +# --------------------------------------------------------------------------- +# These encode the SAP application-platform provisioning convention: the app +# tenant id and subdomain come from the SDK-wide runtime context (populated at +# startup by bootstrap(app)); the CBC URL comes from the tenant-mapping Fragment +# written during provisioning; the mTLS certificate comes from the Destination +# Service. create_client wires these in whenever CBCConfig leaves the matching +# field unset. + + +def _resolve_app_tenant_id() -> str: + """Return the application tenant id from the SDK runtime context. + + Reads :data:`~sap_cloud_sdk.core.runtime_context.APP_TENANT_ID` (the IAS + ``app_tid`` claim, populated by ``bootstrap(app)``). The application tenant + id identifies the subscriber tenant to CBC and is sent as the ``appTenantId`` + query parameter. + + Raises: + CBCConfigError: If no app tenant id is present in the runtime context. + """ + app_tid = get_context().get(APP_TENANT_ID) + if not app_tid: + raise CBCConfigError( + "No app tenant id in the runtime context — ensure bootstrap(app) is " + "wired so the IAS app_tid claim is populated, or override app_tenant_id " + "on CBCConfig." + ) + return app_tid + + +def _resolve_base_url(destination_instance: str) -> str: + """Return the CBC base URL for the current tenant. + + Reads the subscriber subdomain from the SDK runtime context + (:data:`~sap_cloud_sdk.core.runtime_context.DWC_SUBDOMAIN`), fetches the + tenant-mapping Fragment for that subdomain directly by name + (``CBC_TenantMapping_``), and returns its ``cbcUrl`` property + verbatim. + + 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 the subdomain is absent from the runtime context, no + matching fragment is found, or the matched fragment has no ``cbcUrl`` + property. + """ + from sap_cloud_sdk.destination import AccessStrategy, create_fragment_client + from sap_cloud_sdk.destination.exceptions import DestinationError + + subdomain = get_context().get(DWC_SUBDOMAIN) + if not subdomain: + raise CBCConfigError( + "No subdomain in the runtime context — ensure bootstrap(app) is wired " + "so the dwc-subdomain is populated, or override base_url on CBCConfig." + ) + + fragment_name = f"{CBC_FRAGMENT_PREFIX}{subdomain}" + try: + client = create_fragment_client(instance=destination_instance) + fragment = client.get_subaccount_fragment( + fragment_name, + access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + tenant=subdomain, + ) + except DestinationError as exc: + raise CBCConfigError( + f"Could not resolve the CBC URL: fetching tenant-mapping fragment " + f"{fragment_name!r} failed for subdomain={subdomain!r}: {exc}" + ) from exc + if fragment is None: + raise CBCConfigError( + f"No CBC mapping fragment for subdomain={subdomain!r}: " + f"{fragment_name!r} not found." + ) + 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 + + +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 cannot be fetched or is not found. + """ + from sap_cloud_sdk.destination import AccessStrategy, create_certificate_client + from sap_cloud_sdk.destination._cert_loader import _load_pem + from sap_cloud_sdk.destination.exceptions import DestinationError + + try: + cert = create_certificate_client( + instance=destination_instance + ).get_subaccount_certificate( + cert_name, access_strategy=AccessStrategy.PROVIDER_ONLY + ) + except DestinationError as exc: + raise CBCConfigError( + f"Could not fetch the mTLS certificate {cert_name!r} from Destination " + f"Service instance {destination_instance!r}: {exc}" + ) from exc + if cert is None: + raise CBCConfigError( + f"Subaccount certificate {cert_name!r} not found in Destination " + f"Service instance {destination_instance!r}." + ) + return _load_pem(cert.content, p12_password, cert.name) + + +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 on CBCConfig." + ) + 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 + + +def _as_callable(value: str | Callable[[], str]) -> Callable[[], str]: + """Return ``value`` as a callable — wrap a plain string in a lambda.""" + if isinstance(value, str): + return lambda: value + return value + + # --------------------------------------------------------------------------- # Factory function # --------------------------------------------------------------------------- -def create_client( - *, - base_url: Callable[[], str], - app_tenant_id: Callable[[], str], - ssl_context: Callable[[], ssl.SSLContext] | None = None, -) -> CBCClient: - """Create a :class:`DefaultClient`. +def create_client(config: CBCConfig | None = None) -> CBCClient: + """Create a CBC client. + + Called with no arguments, the client infers everything from the SAP + application-platform provisioning conventions: + + - ``app_tenant_id`` and the subscriber subdomain come from the SDK-wide + runtime context (populated once at startup by ``bootstrap(app)``); + - the CBC URL comes from the tenant-mapping Destination Fragment written + during provisioning; + - the mTLS certificate comes from the Destination Service, reloaded + automatically on a TLS handshake failure (certificate rotation). + + Pass a :class:`~sap_cloud_sdk.cbc.config.CBCConfig` to override any individual + piece. Each override wins over its ``CLOUD_SDK_CBC_*`` env var, which in turn + falls back to the platform default. Note that the mTLS certificate loader is + the default **only** in pure provisioning mode: once ``base_url`` or + ``app_tenant_id`` is overridden (e.g. pointing at a local mock), an unset + ``ssl_context`` means plain HTTPS — set ``ssl_context`` explicitly if you still + need a client certificate. Args: - base_url: Callable returning the CBC service base URL, invoked per request. - app_tenant_id: Callable returning the application tenant id, invoked per - request and sent as the ``appTenantId`` query parameter. - ssl_context: Optional callable returning a freshly-built - :class:`ssl.SSLContext` with mTLS loaded. Resolved once at - construction, then re-invoked only on a TLS handshake failure to - reload a rotated certificate. + config: Optional overrides. Leave ``None`` to infer everything from + provisioning. Set ``base_url`` / ``app_tenant_id`` / ``ssl_context`` + to bypass the corresponding default resolver; set + ``destination_instance`` / ``cbc_cert_name`` / ``p12_password`` to + steer the default certificate / fragment lookup. See + :class:`~sap_cloud_sdk.cbc.config.CBCConfig`. Returns: - A configured :class:`DefaultClient`. + A configured :class:`CBCClient`. + + Raises: + CBCConfigError: If the certificate name cannot be resolved, or — when a + request is made — if the certificate cannot be fetched, the runtime + context is missing the tenant id / subdomain, or the tenant-mapping + fragment is missing. """ - return DefaultClient( + if config is None: + config = CBCConfig() + + destination_instance = config.destination_instance + if destination_instance is None: + destination_instance = os.environ.get(ENV_DESTINATION_INSTANCE, "default") + + # app_tenant_id — explicit override else the runtime-context resolver. + if config.app_tenant_id is not None: + app_tenant_id = _as_callable(config.app_tenant_id) + else: + app_tenant_id = _resolve_app_tenant_id + + # base_url — explicit override else the tenant-mapping fragment resolver. + if config.base_url is not None: + base_url = _as_callable(config.base_url) + else: + base_url = lambda: _resolve_base_url(destination_instance) # noqa: E731 + + # ssl_context — explicit override else the Destination-Service cert loader. + # The mTLS loader is the default only in pure provisioning mode (neither + # base_url nor app_tenant_id overridden). Once a caller supplies either axis + # — e.g. pointing at a local mock — an unset ssl_context means plain HTTPS + # (public CA bundle, no client cert), so they are not forced to also set the + # platform landscape env or carry a certificate. The default loader resolves + # the certificate name/password lazily, inside the factory, consistent with + # the other default resolvers that only touch provisioning when invoked. + if config.ssl_context is not None: + ssl_context: Callable[[], ssl.SSLContext] | None = config.ssl_context + elif config.base_url is not None or config.app_tenant_id is not None: + ssl_context = None + else: + cfg_cert_name = config.cbc_cert_name + cfg_password = config.p12_password + + def ssl_context() -> ssl.SSLContext: + cert_name = ( + cfg_cert_name or os.environ.get(ENV_CERT_NAME) or _default_cert_name() + ) + password = ( + cfg_password if cfg_password is not None else _cert_password_from_env() + ) + return _load_ssl_context(destination_instance, cert_name, password) + + return CBCClient( 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 deleted file mode 100644 index dc6f6cec..00000000 --- a/src/sap_cloud_sdk/cbc/client_adapter.py +++ /dev/null @@ -1,312 +0,0 @@ -"""Platform adapter for the CBC (Central Business Configuration) module. - -This is a thin convenience layer over the generic client in -:mod:`sap_cloud_sdk.cbc.client`. It encodes the SAP application-platform -provisioning convention, where the pieces a CBC call needs are standard: - -- the app's own provider-level **Destination**, holding the mTLS certificate; -- the tenant-mapping **Fragment**, carrying the CBC URL (written by the - platform during tenant provisioning). - -The adapter adds **no** capability to the core client — it only supplies the -``base_url`` / ``app_tenant_id`` resolvers and the mTLS ``ssl.SSLContext`` from -those conventions, then delegates to -:func:`~sap_cloud_sdk.cbc.client.create_client`. Agents are the typical -consumer, but any app that follows the same provisioning convention can use it. -Every default is overridable. - -Two ContextVars are **defined here and owned by the SDK**; the app populates -them — e.g. from the IAS JWT ``app_tid`` claim and the ``dwc-subdomain`` -header, though the SDK does not mandate the source. The SDK deliberately does -not know *how* those values are obtained — authentication and the request -pipeline stay with the app. - -The mTLS certificate is fetched from the Destination Service when the client is -built, and **reloaded automatically** if a request later fails the TLS handshake -(e.g. after the certificate rotates), so a long-lived client recovers without -being recreated. - -Quick start:: - - from sap_cloud_sdk import cbc - - cbc_client = cbc.create_agent_client() # once, at startup - - # per request: - cbc.app_tenant_id_var.set(parse_token(bearer).app_tid) - cbc.tenant_subdomain_var.set(request.headers["dwc-subdomain"]) -""" - -from __future__ import annotations - -import os -import ssl -from contextvars import ContextVar - -from sap_cloud_sdk.cbc.client import CBCClient, create_client -from sap_cloud_sdk.cbc.config import CBCDestinationConfig -from sap_cloud_sdk.cbc.exceptions import CBCConfigError - -# --------------------------------------------------------------------------- -# SDK-owned context -# --------------------------------------------------------------------------- - -#: Application tenant id for the current context (typically the IAS ``app_tid`` -#: claim, but the SDK does not mandate the source). -app_tenant_id_var: ContextVar[str] = ContextVar("cbc_app_tenant_id", default="") - -#: Tenant subdomain for the current context (typically the ``dwc-subdomain`` -#: header, but the SDK does not mandate the source). -tenant_subdomain_var: ContextVar[str] = ContextVar("cbc_tenant_subdomain", default="") - -# --------------------------------------------------------------------------- -# Platform conventions -# --------------------------------------------------------------------------- -# The fragment name and certificate name below follow the application-platform -# convention; they are not arbitrary. Override via the args / env vars if your -# setup differs. - -#: Prefix of the Destination Fragment that maps a subdomain to its CBC tenant -#: (platform convention). -CBC_FRAGMENT_PREFIX = "CBC_TenantMapping_" - -#: Template for the app's own provider-level mTLS certificate name -#: (platform convention). -_CERT_NAME_TEMPLATE = "sap-managed-runtime-ias-{landscape}.pem" - -# Env overrides — the defaults cover the common case; set these only to override. - -#: The ``instance`` passed to the destination ``create_fragment_client`` / -#: ``create_certificate_client`` (used for secret resolution in cloud mode, -#: defaults to "default"); it is not the individual destination / cert / -#: fragment, which are selected by name. -ENV_DESTINATION_INSTANCE = "CLOUD_SDK_CBC_DESTINATION_INSTANCE" - -#: Explicit certificate name, overriding the landscape-derived default. -ENV_CERT_NAME = "CLOUD_SDK_CBC_CERTIFICATE_NAME" - -#: Password for the certificate keystore, if encrypted (default: none). -ENV_P12_PASSWORD = "CLOUD_SDK_CBC_P12_PASSWORD" - -#: Platform landscape, used to derive the default certificate name. -ENV_LANDSCAPE = "APPFND_CONHOS_LANDSCAPE" - - -def _default_cert_name() -> str: - """Return the name of the app's provider-level mTLS certificate. - - This is the certificate the app presents to CBC for the mTLS handshake; its - name follows the platform convention :data:`_CERT_NAME_TEMPLATE`, with the - landscape filled in from :data:`ENV_LANDSCAPE`. - - Raises: - CBCConfigError: If :data:`ENV_LANDSCAPE` is not set, so the name cannot - be derived. - """ - landscape = os.environ.get(ENV_LANDSCAPE) - if not landscape: - raise CBCConfigError( - f"Cannot derive the CBC certificate name: {ENV_LANDSCAPE} is not set. " - f"Set it, or pass cbc_cert_name to create_agent_client()." - ) - return _CERT_NAME_TEMPLATE.format(landscape=landscape) - - -def _cert_password_from_env() -> bytes | None: - """Return the cert keystore password from :data:`ENV_P12_PASSWORD`, or ``None``.""" - raw = os.environ.get(ENV_P12_PASSWORD) - return raw.encode() if raw else None - - -# --------------------------------------------------------------------------- -# Default resolvers -# --------------------------------------------------------------------------- - - -def resolve_app_tenant_id() -> str: - """Return the application tenant id from :data:`app_tenant_id_var`. - - Public platform resolver — the default :func:`create_agent_client` wires it - as the ``app_tenant_id`` callable. Also usable directly to compose - :func:`~sap_cloud_sdk.cbc.client.create_client` when overriding only *some* - of the platform defaults (e.g. keep this + :func:`resolve_base_url` but - supply your own ``ssl_context``). - - The application tenant id identifies the subscriber tenant to CBC — e.g. the - subscriber's subaccount id, carried in the IAS JWT ``app_tid`` claim. - - Raises: - CBCConfigError: If the ContextVar is empty. - """ - app_tid = app_tenant_id_var.get() - if not app_tid: - raise CBCConfigError( - "cbc_app_tenant_id ContextVar is empty — set app_tenant_id_var from " - "the IAS app_tid claim." - ) - return app_tid - - -def resolve_base_url(destination_instance: str) -> str: - """Return the CBC base URL for the current tenant. - - Public platform resolver — the default :func:`create_agent_client` wires it - (as ``lambda: resolve_base_url(destination_instance)``) into the - ``base_url`` callable. Also usable directly to compose - :func:`~sap_cloud_sdk.cbc.client.create_client` when overriding only *some* - of the platform defaults. - - Reads :data:`tenant_subdomain_var` and :data:`app_tenant_id_var`, fetches - the tenant-mapping Fragment for the subscriber's subdomain directly by name - (``CBC_TenantMapping_``), and returns its ``cbcUrl`` property - verbatim. - - 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 AccessStrategy, create_fragment_client - from sap_cloud_sdk.destination.exceptions import DestinationError - - subdomain = tenant_subdomain_var.get() - if not subdomain: - raise CBCConfigError( - "cbc_tenant_subdomain ContextVar is empty — set tenant_subdomain_var " - "from the dwc-subdomain header." - ) - app_tenant_id = resolve_app_tenant_id() - - fragment_name = f"{CBC_FRAGMENT_PREFIX}{subdomain}" - try: - client = create_fragment_client(instance=destination_instance) - fragment = client.get_subaccount_fragment( - fragment_name, - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, - tenant=subdomain, - ) - except DestinationError as exc: - raise CBCConfigError( - f"Could not resolve the CBC URL: fetching tenant-mapping fragment " - f"{fragment_name!r} failed for subdomain={subdomain!r} " - f"(appTenantId={app_tenant_id!r}): {exc}" - ) from exc - if fragment is None: - raise CBCConfigError( - f"No CBC mapping fragment for subdomain={subdomain!r} " - f"(appTenantId={app_tenant_id!r}): {fragment_name!r} not found." - ) - 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 - - -def load_ssl_context( - destination_instance: str, cert_name: str, p12_password: bytes | None -) -> ssl.SSLContext: - """Fetch the provider certificate and load it into an :class:`ssl.SSLContext`. - - Public platform resolver — the default :func:`create_agent_client` wires it - (as ``lambda: load_ssl_context(...)``) into the ``ssl_context`` factory. Also - usable directly to compose :func:`~sap_cloud_sdk.cbc.client.create_client` - when overriding only *some* of the platform defaults. - - Args: - destination_instance: The ``instance`` passed to the destination - ``create_certificate_client`` (selects which Destination Service - binding to read, used for secret resolution in cloud mode). - cert_name: Name of the subaccount certificate to fetch. - p12_password: Certificate keystore password, or ``None`` if unencrypted. - - Raises: - CBCConfigError: If the certificate cannot be fetched or is not found. - """ - from sap_cloud_sdk.destination import AccessStrategy, create_certificate_client - from sap_cloud_sdk.destination._cert_loader import _load_pem - from sap_cloud_sdk.destination.exceptions import DestinationError - - try: - cert = create_certificate_client( - instance=destination_instance - ).get_subaccount_certificate( - cert_name, access_strategy=AccessStrategy.PROVIDER_ONLY - ) - except DestinationError as exc: - raise CBCConfigError( - f"Could not fetch the mTLS certificate {cert_name!r} from Destination " - f"Service instance {destination_instance!r}: {exc}" - ) from exc - if cert is None: - raise CBCConfigError( - f"Subaccount certificate {cert_name!r} not found in Destination " - f"Service instance {destination_instance!r}." - ) - return _load_pem(cert.content, p12_password, cert.name) - - -# --------------------------------------------------------------------------- -# Platform constructor -# --------------------------------------------------------------------------- - - -def create_agent_client( - *, - config: CBCDestinationConfig | None = None, -) -> CBCClient: - """Create a CBC client wired for the application platform. - - Resolves ``base_url`` and ``app_tenant_id`` from the two SDK-owned - ContextVars (:data:`app_tenant_id_var`, :data:`tenant_subdomain_var`) each - time a request is made, and loads the mTLS certificate from the Destination - Service. The certificate is reloaded automatically on a TLS handshake - failure, so the client recovers from certificate rotation without being - recreated. - - To inject a pre-built :class:`ssl.SSLContext` (startup injection or tests), - use the generic :func:`~sap_cloud_sdk.cbc.client.create_client` directly. - - Args: - config: Destination-Service inputs (which instance, certificate, and - keystore password to read). Each unset field falls back to its - ``CLOUD_SDK_CBC_*`` env var, then to a platform default — see - :class:`~sap_cloud_sdk.cbc.config.CBCDestinationConfig`. Omit it - entirely to rely on the env vars / defaults for everything. - - Returns: - A configured CBC client. - - Raises: - CBCConfigError: If the certificate name cannot be resolved, or — when a - request is made — if the certificate cannot be fetched, a ContextVar - is empty, or the tenant-mapping fragment is missing. - """ - if config is None: - config = CBCDestinationConfig() - - destination_instance = config.destination_instance - if destination_instance is None: - destination_instance = os.environ.get(ENV_DESTINATION_INSTANCE, "default") - - resolved_cert_name = ( - config.cbc_cert_name or os.environ.get(ENV_CERT_NAME) or _default_cert_name() - ) - resolved_password = ( - config.p12_password - if config.p12_password is not None - else _cert_password_from_env() - ) - - return create_client( - base_url=lambda: resolve_base_url(destination_instance), - app_tenant_id=resolve_app_tenant_id, - ssl_context=lambda: load_ssl_context( - destination_instance, resolved_cert_name, resolved_password - ), - ) diff --git a/src/sap_cloud_sdk/cbc/config.py b/src/sap_cloud_sdk/cbc/config.py index fd9d295d..cbdf39d6 100644 --- a/src/sap_cloud_sdk/cbc/config.py +++ b/src/sap_cloud_sdk/cbc/config.py @@ -1,19 +1,35 @@ -"""Configuration objects for the CBC (Central Business Configuration) module.""" +"""Configuration object for the CBC (Central Business Configuration) module.""" from __future__ import annotations +import ssl +from collections.abc import Callable from dataclasses import dataclass @dataclass -class CBCDestinationConfig: - """Destination-Service inputs for :func:`create_agent_client`. +class CBCConfig: + """Overrides for :func:`~sap_cloud_sdk.cbc.client.create_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``. + Every field is optional. Left unset, :func:`create_client` infers the value + from the SAP application-platform provisioning conventions (runtime context, + tenant-mapping Fragment, Destination Service certificate). A value set here + wins over its ``CLOUD_SDK_CBC_*`` env var, which in turn falls back to the + platform default. + + The first three fields replace a whole default resolver; the last three steer + the default certificate / fragment lookup. Attributes: + base_url: CBC service base URL, as a string or a per-request callable. + Overrides the tenant-mapping Fragment lookup. + app_tenant_id: Application tenant id, as a string or a per-request + callable. Overrides reading ``APP_TENANT_ID`` from the runtime context. + ssl_context: Callable returning a freshly-built :class:`ssl.SSLContext` + with mTLS loaded. Overrides loading the certificate from the + Destination Service. ``None`` falls back to the default loader; use + this to inject a pre-built context (startup injection or tests), or a + plain-HTTP / local-mock path by returning an unauthenticated context. 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 @@ -25,6 +41,9 @@ class CBCDestinationConfig: ``CLOUD_SDK_CBC_P12_PASSWORD`` env var, else ``None``. """ + base_url: str | Callable[[], str] | None = None + app_tenant_id: str | Callable[[], str] | None = None + ssl_context: Callable[[], ssl.SSLContext] | None = 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 159d3978..fcba1db7 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -20,28 +20,28 @@ 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. +The recommended entry point is `create_client()` with no arguments. On the SAP +application platform it infers everything from provisioning: + +- the application tenant id and the subscriber subdomain come from the SDK-wide + runtime context (wire `bootstrap(app)` once at startup — see below); +- the CBC URL comes from the tenant-mapping Destination Fragment written during + provisioning; +- the mTLS certificate comes from the Destination Service. ```python from sap_cloud_sdk import cbc -cbc_client = cbc.create_client( - base_url=lambda: resolve_cbc_url(), - app_tenant_id=lambda: resolve_app_tenant_id(), - ssl_context=lambda: build_ssl_ctx(), -) +cbc_client = cbc.create_client() +config = cbc_client.get_configuration() ``` -`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. +The mTLS context 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). +To override any individual piece — for local development, tests, or a bespoke +deployment — pass a `CBCConfig`. See [Overrides](#overrides). ## Reading configuration @@ -147,7 +147,7 @@ modes = cbc_client.get_entity_data( | `CBCClientError` | 4xx from CBC (e.g. tenant not found) | | `CBCServerError` | 5xx from CBC | | `CBCNetworkError` | connection failure | -| `CBCConfigError` | platform adapter cannot resolve the tenant mapping or certificate | +| `CBCConfigError` | the client cannot resolve the tenant mapping or certificate from provisioning | ```python from sap_cloud_sdk import cbc @@ -162,11 +162,10 @@ except cbc.CBCNetworkError: ## 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. +On the SAP application platform `create_client()` needs no arguments. It reads +the application tenant id and the subscriber subdomain from the SDK-wide +**runtime context**, 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: @@ -174,43 +173,46 @@ The two artifacts it depends on follow the platform convention: - 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). +The runtime context is populated once at startup via `bootstrap(app)`; CBC then +reads `APP_TENANT_ID` and `DWC_SUBDOMAIN` from it per request. You do not set any +CBC-specific context by hand. ```python -from sap_cloud_sdk import cbc +from sap_cloud_sdk import bootstrap, cbc + +bootstrap(app) # wires the runtime context (IAS app_tid, dwc-subdomain, …) # once, at startup — the mTLS context is built here -cbc_client = cbc.create_agent_client() +cbc_client = cbc.create_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" +# per request: nothing CBC-specific to set — the runtime context already carries +# the app tenant id and subdomain for the current request. +config = cbc_client.get_configuration() ``` The CBC URL comes from the `cbcUrl` property of the tenant-mapping fragment. The -adapter lists the `CBC_TenantMapping_*` fragments in the subscriber's subaccount -and picks the one whose `appTenantId` property matches; the `cbcUrl` is used -verbatim. The certificate is the app's own provider-level mTLS certificate, -fetched from the Destination Service. +client fetches the `CBC_TenantMapping_` fragment for the subscriber's +subdomain directly by name and uses its `cbcUrl` verbatim. The certificate is the +app's own provider-level mTLS certificate, fetched from the Destination Service. **Performance note.** The fragment lookup (Destination Service HTTP call) runs on every `get_configuration()` call, because the CBC URL is resolved fresh each -time via the `base_url` callable. For high-throughput agents making frequent CBC -calls, consider caching the resolved URL at the application layer (e.g. per -tenant, invalidated on `CBCClientError` with a not-found code). +time. For high-throughput agents making frequent CBC calls, consider caching the +resolved URL at the application layer (e.g. per tenant, invalidated on +`CBCClientError` with a not-found code). **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. +long-lived `create_client()` singleton therefore recovers from rotation on its +own — no need to recreate it. -### Platform env overrides +## Overrides -Defaults cover the common case; override via env when needed: +`create_client` accepts an optional `CBCConfig`. Any field left unset is inferred +from provisioning; a field you set wins over its `CLOUD_SDK_CBC_*` env var, which +in turn falls back to the platform default. | Variable | Default | Description | |---|---|---| @@ -219,47 +221,51 @@ 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"), +# steer the default certificate lookup +cbc_client = cbc.create_client( + cbc.CBCConfig(cbc_cert_name="my-cert.pem"), ) ``` - -### Overriding one axis - -`create_agent_client` is a fixed preset: it wires all three inputs — `base_url`, -`app_tenant_id`, and the mTLS `ssl_context` — from the platform conventions. To -keep *most* of that but override a single axis (say, supply your own mTLS context -from a vault while keeping the fragment-based `base_url` and the ContextVar -`app_tenant_id`), compose the generic `create_client` with the public platform -resolvers: +`CBCConfig` also exposes three override hooks that replace a whole default +resolver — `base_url`, `app_tenant_id` (each a string or a per-request callable), +and `ssl_context`. Set only the axis you want to control: ```python from sap_cloud_sdk import cbc +# e.g. supply your own mTLS context from a vault, keep provisioning for the rest 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 + cbc.CBCConfig(ssl_context=lambda: my_ctx), ) ``` -`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 +The `ssl_context` callable you pass still participates in the automatic reload-on-TLS-failure rotation described above. +> **Note.** The mTLS loader is the default **only** in pure provisioning mode. +> Once you override `base_url` or `app_tenant_id` (for example, to point at a +> local mock), an unset `ssl_context` means plain HTTPS with the public CA bundle +> — set `ssl_context` explicitly if you still need a client certificate. + +```python +# local mock: plain HTTP, no certificate needed +client = cbc.create_client( + cbc.CBCConfig( + base_url="http://localhost:8001", + app_tenant_id="my-app-tenant", + ), +) +``` + ## Using a test double -`CBCClient` is a `Protocol` — implement it directly in tests: +`create_client` returns a concrete `CBCClient`. For tests, inject a mock +`httpx.Client` when constructing one directly, or substitute your own stub object +that implements the three public methods: ```python from sap_cloud_sdk import cbc @@ -273,6 +279,7 @@ class StubCBCClient: app_tenant_id="app-t1", config_objects=[], ) + def get_entity_data(self, config_object_id, entity_id, consumption_version=None): ... def test_my_service(): diff --git a/tests/cbc/integration/conftest.py b/tests/cbc/integration/conftest.py index 7bea7835..776432ca 100644 --- a/tests/cbc/integration/conftest.py +++ b/tests/cbc/integration/conftest.py @@ -19,7 +19,7 @@ import pytest -from sap_cloud_sdk.cbc import CBCClient, create_client +from sap_cloud_sdk.cbc import CBCClient, CBCConfig, create_client ENV_URL = "CLOUD_SDK_CBC_URL" ENV_APP_TENANT_ID = "CLOUD_SDK_CBC_APP_TENANT_ID" @@ -58,7 +58,9 @@ def cbc_client() -> CBCClient: pytest.skip(f"CBC integration tests skipped — set {ENV_URL}.") ctx = _build_ssl_context() return create_client( - base_url=lambda: url, - app_tenant_id=lambda: app_tid, - ssl_context=(lambda: ctx) if ctx is not None else None, + CBCConfig( + base_url=url, + app_tenant_id=app_tid, + ssl_context=(lambda: ctx) if ctx is not None else None, + ) ) diff --git a/tests/cbc/integration/test_e2e_bdd.py b/tests/cbc/integration/test_e2e_bdd.py index afcf0c4b..145af7ca 100644 --- a/tests/cbc/integration/test_e2e_bdd.py +++ b/tests/cbc/integration/test_e2e_bdd.py @@ -13,7 +13,7 @@ from pytest_bdd import given, scenario, then, when from sap_cloud_sdk.cbc import ConfigData, ConsumptionVersions, EntityData -from sap_cloud_sdk.cbc.client import DefaultClient +from sap_cloud_sdk.cbc.client import CBCClient pytestmark = pytest.mark.integration @@ -68,17 +68,17 @@ def test_get_entity_data_returns_entity_data(): @given("a configured CBC client and tenant context") -def cbc_context(cbc_client: DefaultClient, cbc_app_tenant_id: str): +def cbc_context(cbc_client: CBCClient, cbc_app_tenant_id: str): pass @when("I call get_consumption_versions") -def call_get_consumption_versions(ctx: dict, cbc_client: DefaultClient): +def call_get_consumption_versions(ctx: dict, cbc_client: CBCClient): ctx["versions"] = cbc_client.get_consumption_versions() @when("I call get_configuration") -def call_get_configuration(ctx: dict, cbc_client: DefaultClient): +def call_get_configuration(ctx: dict, cbc_client: CBCClient): ctx["config"] = cbc_client.get_configuration() @@ -140,7 +140,7 @@ def assert_entity_data_accessible(ctx: dict): @when("I call get_entity_data for the first entity in the first config object") -def call_get_entity_data(ctx: dict, cbc_client: DefaultClient): +def call_get_entity_data(ctx: dict, cbc_client: CBCClient): config: ConfigData = cbc_client.get_configuration() assert config.config_objects, "no config objects — cannot test get_entity_data" co = config.config_objects[0] diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index 9a8b5ff1..5e19318f 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -1,4 +1,4 @@ -"""Unit tests for DefaultClient and create_client.""" +"""Unit tests for CBCClient and create_client.""" from __future__ import annotations @@ -11,7 +11,8 @@ import pytest import ssl -from sap_cloud_sdk.cbc.client import DefaultClient, create_client, _is_tls_failure +from sap_cloud_sdk.cbc.client import CBCClient, create_client, _is_tls_failure +from sap_cloud_sdk.cbc.config import CBCConfig from sap_cloud_sdk.cbc.exceptions import ( CBCClientError, CBCConfigError, @@ -48,9 +49,9 @@ def _mock_response( def _make_client( base_url: str = "https://cbc.example.ondemand.com", app_tenant_id: Callable[[], str] | None = None, -) -> tuple[DefaultClient, MagicMock]: +) -> tuple[CBCClient, MagicMock]: mock_http = MagicMock(spec=httpx.Client) - client = DefaultClient( + client = CBCClient( base_url=lambda: base_url, app_tenant_id=app_tenant_id or _app_tid(), http_client=mock_http, @@ -59,7 +60,7 @@ def _make_client( # --------------------------------------------------------------------------- -# DefaultClient — app_tenant_id callable +# CBCClient — app_tenant_id callable # --------------------------------------------------------------------------- @@ -76,7 +77,7 @@ def app_tenant_id_fn() -> str: mock_http.request.return_value = _mock_response( json_body={"items": [{"version": "cv1"}]} ) - client = DefaultClient( + client = CBCClient( base_url=lambda: "https://cbc.example.ondemand.com", app_tenant_id=app_tenant_id_fn, http_client=mock_http, @@ -87,7 +88,7 @@ def app_tenant_id_fn() -> str: # --------------------------------------------------------------------------- -# DefaultClient — URL building +# CBCClient — URL building # --------------------------------------------------------------------------- @@ -104,7 +105,7 @@ def test_configurations_url_with_base_and_suffix_joins_correctly(self): # --------------------------------------------------------------------------- -# DefaultClient — get_consumption_versions +# CBCClient — get_consumption_versions # --------------------------------------------------------------------------- @@ -141,7 +142,7 @@ def test_get_consumption_versions_on_connection_failure_raises_cbc_network_error # --------------------------------------------------------------------------- -# DefaultClient — _get_configuration_objects +# CBCClient — _get_configuration_objects # --------------------------------------------------------------------------- @@ -177,7 +178,7 @@ def test_get_configuration_objects_with_multiple_objects_returns_grouped_config_ # --------------------------------------------------------------------------- -# DefaultClient — _fetch_entity_data +# CBCClient — _fetch_entity_data # --------------------------------------------------------------------------- @@ -234,7 +235,7 @@ def test_fetch_entity_data_with_absent_content_defaults_to_empty_list(self): # --------------------------------------------------------------------------- -# DefaultClient — get_configuration +# CBCClient — get_configuration # --------------------------------------------------------------------------- @@ -292,7 +293,7 @@ def base_url_fn() -> str: return "https://cbc.example.ondemand.com" mock_http = MagicMock(spec=httpx.Client) - client = DefaultClient( + client = CBCClient( base_url=base_url_fn, app_tenant_id=_app_tid(), http_client=mock_http, @@ -326,7 +327,7 @@ def base_url_fn() -> str: # --------------------------------------------------------------------------- -# DefaultClient — get_entity_data +# CBCClient — get_entity_data # --------------------------------------------------------------------------- @@ -371,11 +372,11 @@ def test_get_entity_data_when_no_versions_exist_raises_cbc_client_error(self): # --------------------------------------------------------------------------- -# DefaultClient — context manager +# CBCClient — context manager # --------------------------------------------------------------------------- -class TestDefaultClientContextManager: +class TestCBCClientContextManager: def test_context_manager_on_exit_closes_http_client(self): client, mock_http = _make_client() with client: @@ -384,28 +385,48 @@ def test_context_manager_on_exit_closes_http_client(self): # --------------------------------------------------------------------------- -# create_client +# create_client — override path (CBCConfig axes) # --------------------------------------------------------------------------- class TestCreateClient: - def test_create_client_with_required_args_returns_default_client(self): + def test_create_client_with_base_url_and_app_tenant_id_overrides_returns_client(self): client = create_client( - base_url=lambda: "http://localhost:8001", - app_tenant_id=lambda: "app-t1", + CBCConfig( + base_url="http://localhost:8001", + app_tenant_id="app-t1", + ) ) - assert isinstance(client, DefaultClient) + assert isinstance(client, CBCClient) + # String overrides are wrapped into callables and resolved per request. + assert client._base_url() == "http://localhost:8001" + assert client._resolve_app_tenant_id() == "app-t1" - def test_create_client_with_ssl_context_passes_it_through_to_default_client(self): - import ssl + def test_create_client_with_callable_overrides_are_invoked_per_request(self): + client = create_client( + CBCConfig( + base_url=lambda: "https://cbc.example.ondemand.com", + app_tenant_id=lambda: "app-t1", + ) + ) + assert isinstance(client, CBCClient) + assert client._base_url() == "https://cbc.example.ondemand.com" + assert client._resolve_app_tenant_id() == "app-t1" + def test_create_client_with_ssl_context_override_passes_it_through(self): ctx = ssl.create_default_context() client = create_client( - base_url=lambda: "https://cbc.example.ondemand.com", - app_tenant_id=lambda: "app-t1", - ssl_context=lambda: ctx, + CBCConfig( + base_url="https://cbc.example.ondemand.com", + app_tenant_id="app-t1", + ssl_context=lambda: ctx, + ) ) - assert isinstance(client, DefaultClient) + assert isinstance(client, CBCClient) + factory = client._ssl_factory + assert factory is not None + assert factory() is ctx + # --------------------------------------------------------------------------- @@ -440,15 +461,15 @@ def test_is_tls_failure_with_non_ssl_os_error_returns_false(self): # --------------------------------------------------------------------------- -# DefaultClient — mTLS certificate rotation (reactive rebuild) +# CBCClient — 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. + ) -> tuple[CBCClient, list[MagicMock]]: + """A CBCClient 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. @@ -466,7 +487,7 @@ def build() -> httpx.Client: initial = MagicMock(spec=httpx.Client) built.append(initial) - client = DefaultClient( + client = CBCClient( base_url=lambda: "https://cbc.example.ondemand.com", app_tenant_id=_app_tid(), http_client=initial, @@ -540,7 +561,7 @@ def factory() -> ssl.SSLContext: def test_cert_rotation_without_ssl_factory_does_not_rebuild_client(self): mock_http = MagicMock(spec=httpx.Client) mock_http.request.side_effect = _tls_read_error() - client = DefaultClient( + client = CBCClient( base_url=lambda: "https://cbc.example.ondemand.com", app_tenant_id=_app_tid(), http_client=mock_http, @@ -563,7 +584,7 @@ def factory() -> ssl.SSLContext: mock_http = MagicMock(spec=httpx.Client) mock_http.request.side_effect = _tls_read_error() - client = DefaultClient( + client = CBCClient( base_url=lambda: "https://cbc.example.ondemand.com", app_tenant_id=_app_tid(), http_client=mock_http, diff --git a/tests/cbc/unit/test_client_adapter.py b/tests/cbc/unit/test_create_client.py similarity index 63% rename from tests/cbc/unit/test_client_adapter.py rename to tests/cbc/unit/test_create_client.py index 8ebcbe83..f57bc7b0 100644 --- a/tests/cbc/unit/test_client_adapter.py +++ b/tests/cbc/unit/test_create_client.py @@ -1,4 +1,10 @@ -"""Unit tests for the CBC platform adapter.""" +"""Unit tests for the default platform resolvers and create_client's wiring. + +These cover the behaviour that used to live in the platform adapter: resolving +``app_tenant_id`` / ``base_url`` from the SDK runtime context, loading the mTLS +certificate from the Destination Service, and the config → env → platform-default +precedence that ``create_client`` applies when a :class:`CBCConfig` axis is unset. +""" from __future__ import annotations @@ -7,55 +13,64 @@ import pytest -from sap_cloud_sdk.cbc.client_adapter import ( +from sap_cloud_sdk.cbc.client import ( CBC_FRAGMENT_PREFIX, ENV_CERT_NAME, ENV_DESTINATION_INSTANCE, ENV_LANDSCAPE, - app_tenant_id_var, - create_agent_client, - load_ssl_context, - resolve_app_tenant_id, - resolve_base_url, - tenant_subdomain_var, + CBCClient, + _load_ssl_context, + _resolve_app_tenant_id, + _resolve_base_url, + create_client, ) -from sap_cloud_sdk.cbc.client import DefaultClient, create_client -from sap_cloud_sdk.cbc.config import CBCDestinationConfig +from sap_cloud_sdk.cbc.config import CBCConfig from sap_cloud_sdk.cbc.exceptions import CBCConfigError +from sap_cloud_sdk.core.runtime_context import APP_TENANT_ID, DWC_SUBDOMAIN +from sap_cloud_sdk.core.runtime_context._context import RuntimeContext, set_context from sap_cloud_sdk.destination import AccessStrategy @pytest.fixture(autouse=True) -def _reset_contextvars(): - app_tenant_id_var.set("") - tenant_subdomain_var.set("") +def _reset_runtime_context(): + """Each test starts from an empty runtime context.""" + set_context(RuntimeContext()) yield + set_context(RuntimeContext()) + + +def _seed_context(app_tenant_id: str = "", subdomain: str = "") -> None: + values = {} + if app_tenant_id: + values[APP_TENANT_ID] = app_tenant_id + if subdomain: + values[DWC_SUBDOMAIN] = subdomain + set_context(RuntimeContext(values)) # --------------------------------------------------------------------------- -# resolve_app_tenant_id +# _resolve_app_tenant_id # --------------------------------------------------------------------------- class TestResolveAppTenantId: - def test_resolve_app_tenant_id_with_set_contextvar_returns_value(self): - app_tenant_id_var.set("app-t1") - assert resolve_app_tenant_id() == "app-t1" + def test_resolve_app_tenant_id_with_context_value_returns_it(self): + _seed_context(app_tenant_id="app-t1") + assert _resolve_app_tenant_id() == "app-t1" - def test_resolve_app_tenant_id_with_empty_contextvar_raises_cbc_config_error(self): - with pytest.raises(CBCConfigError, match="cbc_app_tenant_id"): - resolve_app_tenant_id() + def test_resolve_app_tenant_id_with_empty_context_raises_cbc_config_error(self): + with pytest.raises(CBCConfigError, match="app tenant id"): + _resolve_app_tenant_id() # --------------------------------------------------------------------------- -# resolve_base_url +# _resolve_base_url # --------------------------------------------------------------------------- class TestResolveBaseUrl: def test_resolve_base_url_with_matching_fragment_returns_cbc_url(self): - tenant_subdomain_var.set("appfnd-subscriber") - app_tenant_id_var.set("app-t1") + _seed_context(app_tenant_id="app-t1", subdomain="appfnd-subscriber") match = MagicMock() match.name = f"{CBC_FRAGMENT_PREFIX}appfnd-subscriber" match.properties = { @@ -69,7 +84,7 @@ def test_resolve_base_url_with_matching_fragment_returns_cbc_url(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.get_subaccount_fragment.assert_called_once_with( @@ -79,18 +94,12 @@ def test_resolve_base_url_with_matching_fragment_returns_cbc_url(self): ) def test_resolve_base_url_with_empty_subdomain_raises_cbc_config_error(self): - app_tenant_id_var.set("app-t1") - with pytest.raises(CBCConfigError, match="cbc_tenant_subdomain"): - resolve_base_url("default") - - def test_resolve_base_url_with_empty_app_tenant_id_raises_cbc_config_error(self): - tenant_subdomain_var.set("appfnd-subscriber") - with pytest.raises(CBCConfigError, match="cbc_app_tenant_id"): - resolve_base_url("default") + _seed_context(app_tenant_id="app-t1") + with pytest.raises(CBCConfigError, match="subdomain"): + _resolve_base_url("default") def test_resolve_base_url_with_no_matching_fragment_raises_cbc_config_error(self): - tenant_subdomain_var.set("appfnd-subscriber") - app_tenant_id_var.set("app-t1") + _seed_context(app_tenant_id="app-t1", subdomain="appfnd-subscriber") fake_client = MagicMock() fake_client.get_subaccount_fragment.return_value = None with patch( @@ -98,11 +107,10 @@ def test_resolve_base_url_with_no_matching_fragment_raises_cbc_config_error(self return_value=fake_client, ): with pytest.raises(CBCConfigError, match="No CBC mapping fragment"): - resolve_base_url("default") + _resolve_base_url("default") def test_resolve_base_url_with_fragment_missing_cbc_url_raises_cbc_config_error(self): - tenant_subdomain_var.set("appfnd-subscriber") - app_tenant_id_var.set("app-t1") + _seed_context(app_tenant_id="app-t1", subdomain="appfnd-subscriber") match = MagicMock() match.name = f"{CBC_FRAGMENT_PREFIX}appfnd-subscriber" match.properties = {"appTenantId": "app-t1"} @@ -113,13 +121,12 @@ def test_resolve_base_url_with_fragment_missing_cbc_url_raises_cbc_config_error( return_value=fake_client, ): with pytest.raises(CBCConfigError, match="no 'cbcUrl'"): - resolve_base_url("default") + _resolve_base_url("default") def test_resolve_base_url_when_destination_raises_wraps_as_cbc_config_error(self): from sap_cloud_sdk.destination.exceptions import DestinationOperationError - tenant_subdomain_var.set("appfnd-subscriber") - app_tenant_id_var.set("app-t1") + _seed_context(app_tenant_id="app-t1", subdomain="appfnd-subscriber") fake_client = MagicMock() fake_client.get_subaccount_fragment.side_effect = DestinationOperationError( "failed to get fragment: token error" @@ -129,11 +136,11 @@ def test_resolve_base_url_when_destination_raises_wraps_as_cbc_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 # --------------------------------------------------------------------------- @@ -156,7 +163,7 @@ def test_load_ssl_context_with_valid_cert_returns_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") @@ -169,7 +176,7 @@ def test_load_ssl_context_when_cert_not_found_raises_cbc_config_error(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_load_ssl_context_when_destination_raises_wraps_as_cbc_config_error(self): from sap_cloud_sdk.destination.exceptions import DestinationOperationError @@ -185,25 +192,24 @@ def test_load_ssl_context_when_destination_raises_wraps_as_cbc_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) # --------------------------------------------------------------------------- -# create_agent_client +# create_client — default wiring (infer from provisioning) + precedence # --------------------------------------------------------------------------- -class TestCreateAgentClient: - def test_create_agent_client_with_landscape_env_builds_ssl_context_from_cert(self, monkeypatch): +class TestCreateClientDefaults: + def test_default_wiring_with_landscape_env_builds_ssl_context_from_cert(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._load_ssl_context", return_value=ssl.create_default_context(), ) as load: - client = create_agent_client() - assert isinstance(client, DefaultClient) - # The adapter wires a factory that resolves the cert; the core - # invokes it once at construction to build the mTLS client. + client = create_client() + assert isinstance(client, CBCClient) + # The factory is resolved 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" @@ -213,25 +219,25 @@ def test_create_agent_client_with_landscape_env_builds_ssl_context_from_cert(sel client._ssl_factory() assert load.call_count == 2 - def test_create_agent_client_with_env_overrides_applies_instance_and_cert_name(self, monkeypatch): + def test_env_overrides_apply_instance_and_cert_name(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._load_ssl_context", return_value=ssl.create_default_context(), ) as load: - create_agent_client() + create_client() instance_arg, cert_arg, _pw = load.call_args.args assert instance_arg == "cbc-instance" assert cert_arg == "my-cert.pem" - def test_create_agent_client_with_config_object_applies_instance_and_cert_name(self): + def test_config_object_applies_instance_and_cert_name(self): with patch( - "sap_cloud_sdk.cbc.client_adapter.load_ssl_context", + "sap_cloud_sdk.cbc.client._load_ssl_context", return_value=ssl.create_default_context(), ) as load: - create_agent_client( - config=CBCDestinationConfig( + create_client( + CBCConfig( destination_instance="cbc-instance", cbc_cert_name="my-cert.pem", ) @@ -240,15 +246,15 @@ def test_create_agent_client_with_config_object_applies_instance_and_cert_name(s assert instance_arg == "cbc-instance" assert cert_arg == "my-cert.pem" - def test_create_agent_client_with_config_and_env_set_config_value_wins(self, monkeypatch): + 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", + "sap_cloud_sdk.cbc.client._load_ssl_context", return_value=ssl.create_default_context(), ) as load: - create_agent_client( - config=CBCDestinationConfig( + create_client( + CBCConfig( destination_instance="from-config", cbc_cert_name="from-config.pem", ) @@ -257,26 +263,25 @@ def test_create_agent_client_with_config_and_env_set_config_value_wins(self, mon assert instance_arg == "from-config" assert cert_arg == "from-config.pem" - def test_create_agent_client_without_landscape_or_cert_name_raises_cbc_config_error(self, monkeypatch): + def test_without_landscape_or_cert_name_raises_cbc_config_error(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() + create_client() - def test_create_agent_client_with_contextvars_set_wires_resolvers_into_client(self, monkeypatch): - """The two ContextVars drive base_url + app_tenant_id at request time.""" + def test_default_resolvers_read_runtime_context_at_request_time(self, monkeypatch): + """With no overrides, base_url + app_tenant_id come from the runtime context.""" monkeypatch.setenv(ENV_CERT_NAME, "my-cert.pem") with patch( - "sap_cloud_sdk.cbc.client_adapter.load_ssl_context", + "sap_cloud_sdk.cbc.client._load_ssl_context", return_value=ssl.create_default_context(), ): - client = create_agent_client() - assert isinstance(client, DefaultClient) + client = create_client() + assert isinstance(client, CBCClient) - app_tenant_id_var.set("app-t1") + _seed_context(app_tenant_id="app-t1", subdomain="appfnd-subscriber") assert client._resolve_app_tenant_id() == "app-t1" - tenant_subdomain_var.set("appfnd-subscriber") fragment = MagicMock() fragment.name = f"{CBC_FRAGMENT_PREFIX}appfnd-subscriber" fragment.properties = { @@ -289,20 +294,3 @@ def test_create_agent_client_with_contextvars_set_wires_resolvers_into_client(se "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_builds_valid_default_client(self): - """Advanced callers keep platform base_url + app_tenant_id but bring - their own mTLS context, composing the public resolvers with the core.""" - client = create_client( - base_url=lambda: resolve_base_url("default"), - app_tenant_id=resolve_app_tenant_id, - ssl_context=lambda: ssl.create_default_context(), - ) - assert isinstance(client, DefaultClient) diff --git a/uv.lock b/uv.lock index f6cd4a21..018d6de9 100644 --- a/uv.lock +++ b/uv.lock @@ -166,9 +166,9 @@ name = "aiologic" version = "0.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio" }, - { name = "typing-extensions" }, - { name = "wrapt" }, + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f1/7a/d51f2fde1e8ae8a83431f8e97b7a71e9358cdb1d4d2ce6be387fa44d68de/aiologic-0.17.1.tar.gz", hash = "sha256:2e1b93b9e88ced318c2a63ad7b382688f40cbfe40e3d42258d49dc9c5aea179d", size = 252354, upload-time = "2026-06-27T20:41:33.25Z" } wheels = [ @@ -816,8 +816,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic" }, - { name = "typing-extensions" }, + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ From 08ce6b98b9a443b49f56a4b3d5fee48347083502 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Wed, 14 Oct 2026 20:16:23 +0530 Subject: [PATCH 21/26] test(cbc): drive integration tests through the default provisioning path Rework the CBC integration harness to match agentgateway: instead of overriding base_url + app_tenant_id and loading the mTLS cert from local PEM files, supply the Destination Service binding and let cbc.create_client() resolve the CBC URL (tenant-mapping Fragment) and the mTLS certificate (Destination Service) itself. This exercises the real default provisioning path rather than the override path. The conftest seeds the SDK runtime context with the subscriber tenant id and subdomain (what bootstrap(app) populates from a request), then calls create_client() with no overrides. Harness env vars follow agw's CLOUD_SDK_CFG__DEFAULT_ convention: CLOUD_SDK_CFG_DESTINATION_DEFAULT_* (binding, shared SDK vars) CLOUD_SDK_CFG_CBC_DEFAULT_APP_TENANT_ID CLOUD_SDK_CFG_CBC_DEFAULT_TENANT_SUBDOMAIN CLOUD_SDK_CFG_CBC_DEFAULT_LANDSCAPE Tests skip cleanly when the binding or tenant values are absent. --- tests/cbc/integration/conftest.py | 100 ++++++++++++++++---------- tests/cbc/integration/test_e2e_bdd.py | 16 +++-- 2 files changed, 76 insertions(+), 40 deletions(-) diff --git a/tests/cbc/integration/conftest.py b/tests/cbc/integration/conftest.py index 776432ca..95a9e39a 100644 --- a/tests/cbc/integration/conftest.py +++ b/tests/cbc/integration/conftest.py @@ -1,30 +1,53 @@ """Pytest fixtures for CBC integration tests. -Tests target a real or mock CBC server. Configuration is read from env vars: +Tests exercise the real default provisioning path: ``cbc.create_client()`` with +no overrides, resolving the CBC URL from the tenant-mapping Destination Fragment +and the mTLS certificate from the Destination Service — the same way the +agentgateway integration tests drive ``create_client``. - CLOUD_SDK_CBC_URL CBC service base URL (required) - CLOUD_SDK_CBC_APP_TENANT_ID Application tenant ID (required) - CLOUD_SDK_CBC_CERT_PATH Client certificate PEM path (optional, mTLS) - CLOUD_SDK_CBC_KEY_PATH Client private key PEM path (optional, mTLS) +Configuration is read from the environment (optionally from a +``.env_integration_tests`` file at the repo root, loaded automatically): -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. + # Destination Service binding (standard SDK convention, instance "default") + CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTID + CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTSECRET + CLOUD_SDK_CFG_DESTINATION_DEFAULT_URL + CLOUD_SDK_CFG_DESTINATION_DEFAULT_URI + CLOUD_SDK_CFG_DESTINATION_DEFAULT_IDENTITYZONE + + # CBC tenant to target (the client reads these from the runtime context) + CLOUD_SDK_CFG_CBC_DEFAULT_APP_TENANT_ID IAS app_tid of the subscriber tenant + CLOUD_SDK_CFG_CBC_DEFAULT_TENANT_SUBDOMAIN subscriber subdomain (selects the fragment) + + # landscape, used to derive the mTLS certificate name + CLOUD_SDK_CFG_CBC_DEFAULT_LANDSCAPE -> APPFND_CONHOS_LANDSCAPE + +When the binding or the tenant values are missing, integration tests skip. """ from __future__ import annotations import os -import ssl +from pathlib import Path import pytest +from dotenv import load_dotenv -from sap_cloud_sdk.cbc import CBCClient, CBCConfig, create_client +from sap_cloud_sdk.cbc import CBCClient, create_client +from sap_cloud_sdk.core.runtime_context import APP_TENANT_ID, DWC_SUBDOMAIN +from sap_cloud_sdk.core.runtime_context._context import RuntimeContext, set_context -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" +ENV_APP_TENANT_ID = "CLOUD_SDK_CFG_CBC_DEFAULT_APP_TENANT_ID" +ENV_TENANT_SUBDOMAIN = "CLOUD_SDK_CFG_CBC_DEFAULT_TENANT_SUBDOMAIN" +ENV_LANDSCAPE = "CLOUD_SDK_CFG_CBC_DEFAULT_LANDSCAPE" +ENV_DESTINATION_CLIENT_ID = "CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTID" + + +def _setup_cloud_mode() -> None: + """Load environment variables from .env_integration_tests if present.""" + env_file = Path(__file__).parents[3] / ".env_integration_tests" + if env_file.exists(): + load_dotenv(env_file) def _require_app_tenant_id() -> str: @@ -34,33 +57,38 @@ def _require_app_tenant_id() -> str: return app_tid -def _build_ssl_context() -> ssl.SSLContext | None: - """Build an mTLS context from the cert/key path env vars, or None if unset.""" - cert_path = os.environ.get(ENV_CERT_PATH) - key_path = os.environ.get(ENV_KEY_PATH) - if not cert_path or not key_path: - return None - ctx = ssl.create_default_context() - ctx.load_cert_chain(certfile=cert_path, keyfile=key_path) - return ctx - - @pytest.fixture(scope="session") def cbc_app_tenant_id() -> str: + _setup_cloud_mode() return _require_app_tenant_id() -@pytest.fixture(scope="session") +@pytest.fixture def cbc_client() -> CBCClient: - app_tid = _require_app_tenant_id() - url = os.environ.get(ENV_URL) - if not url: - pytest.skip(f"CBC integration tests skipped — set {ENV_URL}.") - ctx = _build_ssl_context() - return create_client( - CBCConfig( - base_url=url, - app_tenant_id=app_tid, - ssl_context=(lambda: ctx) if ctx is not None else None, + """Build the client via the default provisioning path (no overrides). + + Seeds the SDK runtime context with the subscriber tenant id and subdomain — + what ``bootstrap(app)`` would populate from an incoming request — then lets + ``create_client()`` resolve the URL and mTLS certificate from the Destination + Service, exactly as it does in production. + """ + _setup_cloud_mode() + + if not os.environ.get(ENV_DESTINATION_CLIENT_ID): + pytest.skip( + f"CBC integration tests skipped — set the Destination binding " + f"({ENV_DESTINATION_CLIENT_ID} and friends)." ) + app_tid = _require_app_tenant_id() + subdomain = os.environ.get(ENV_TENANT_SUBDOMAIN) + if not subdomain: + pytest.skip(f"CBC integration tests skipped — set {ENV_TENANT_SUBDOMAIN}.") + + landscape = os.environ.get(ENV_LANDSCAPE) + if landscape: + os.environ.setdefault("APPFND_CONHOS_LANDSCAPE", landscape) + + set_context( + RuntimeContext({APP_TENANT_ID: app_tid, DWC_SUBDOMAIN: subdomain}) ) + return create_client() diff --git a/tests/cbc/integration/test_e2e_bdd.py b/tests/cbc/integration/test_e2e_bdd.py index 145af7ca..03610f16 100644 --- a/tests/cbc/integration/test_e2e_bdd.py +++ b/tests/cbc/integration/test_e2e_bdd.py @@ -1,9 +1,17 @@ """BDD integration tests for the CBC (Central Business Configuration) module. -Run against a real or mock CBC server:: - - CLOUD_SDK_CBC_URL=http://localhost:8001 \\ - CLOUD_SDK_CBC_APP_TENANT_ID=my-app-tenant \\ +Run against a real CBC server via the default provisioning path. Supply the +Destination Service binding and the target tenant (see conftest.py for the full +list), then:: + + CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTID=... \\ + CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTSECRET=... \\ + CLOUD_SDK_CFG_DESTINATION_DEFAULT_URL=... \\ + CLOUD_SDK_CFG_DESTINATION_DEFAULT_URI=... \\ + CLOUD_SDK_CFG_DESTINATION_DEFAULT_IDENTITYZONE=... \\ + CLOUD_SDK_CFG_CBC_DEFAULT_APP_TENANT_ID=... \\ + CLOUD_SDK_CFG_CBC_DEFAULT_TENANT_SUBDOMAIN=... \\ + CLOUD_SDK_CFG_CBC_DEFAULT_LANDSCAPE=... \\ pytest tests/cbc/integration """ From fa0e0131378e0490253ac93bbbb53f15766e4510 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Wed, 14 Oct 2026 23:20:16 +0530 Subject: [PATCH 22/26] =?UTF-8?q?refactor(cbc):=20rename=20config=E2=86=92?= =?UTF-8?q?configuration=20in=20public=20API;=20drop=20unused=20NNV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on PR #333: - Rename the business-config vocabulary to match the CBC API's own `configuration` wording: ConfigObject→ConfigurationObject, ConfigData→ConfigurationData, ConfigEntity→ConfigurationEntity (and the internal ConfigurationObjectEntity/Entry/List), get_config_object→ get_configuration_object, and the config_object_id / config_objects fields and parameters. Test identifiers renamed to match. - CBCConfig and CBCConfigError keep the short form — they are SDK client configuration (create_client overrides / setup errors), not CBC's managed business configuration. - Drop the NNV model and the ConsumptionVersion.ref_content field it backed: parsed from the API response but never read anywhere. 63 unit tests pass; integration tests verified against a live CBC tenant. --- src/sap_cloud_sdk/cbc/__init__.py | 16 +++---- src/sap_cloud_sdk/cbc/_models.py | 63 +++++++++++---------------- src/sap_cloud_sdk/cbc/client.py | 48 ++++++++++---------- src/sap_cloud_sdk/cbc/user-guide.md | 41 +++++++++-------- tests/cbc/integration/cbc.feature | 4 +- tests/cbc/integration/conftest.py | 4 +- tests/cbc/integration/test_e2e_bdd.py | 48 ++++++++++---------- tests/cbc/unit/test_client.py | 63 +++++++++++++++++---------- tests/cbc/unit/test_create_client.py | 10 +++-- tests/cbc/unit/test_models.py | 50 +++++++++++---------- 10 files changed, 184 insertions(+), 163 deletions(-) diff --git a/src/sap_cloud_sdk/cbc/__init__.py b/src/sap_cloud_sdk/cbc/__init__.py index 0d098a74..58ac0177 100644 --- a/src/sap_cloud_sdk/cbc/__init__.py +++ b/src/sap_cloud_sdk/cbc/__init__.py @@ -18,7 +18,7 @@ config = cbc_client.get_configuration() # Access entity data - payment = config.get_config_object("payment-config") + payment = config.get_configuration_object("payment-config") if payment: for row in payment.get_entity_data("payment-mode").as_list(): print(row) @@ -47,13 +47,12 @@ ) from sap_cloud_sdk.cbc._models import ( ApiError, - ConfigData, - ConfigEntity, - ConfigObject, + ConfigurationData, + ConfigurationEntity, + ConfigurationObject, ConsumptionVersion, ConsumptionVersions, EntityData, - NNV, ) @@ -75,13 +74,12 @@ # models — consumption versions "ConsumptionVersion", "ConsumptionVersions", - "NNV", # models — entities "EntityData", - "ConfigEntity", - "ConfigObject", + "ConfigurationEntity", + "ConfigurationObject", # models — configuration - "ConfigData", + "ConfigurationData", # models — api error "ApiError", ] diff --git a/src/sap_cloud_sdk/cbc/_models.py b/src/sap_cloud_sdk/cbc/_models.py index 2fbe4264..672c8df5 100644 --- a/src/sap_cloud_sdk/cbc/_models.py +++ b/src/sap_cloud_sdk/cbc/_models.py @@ -24,17 +24,6 @@ class _FrozenModel(BaseModel): # --------------------------------------------------------------------------- -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. @@ -42,13 +31,11 @@ class ConsumptionVersion(_FrozenModel): 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): @@ -85,7 +72,7 @@ def latest(self) -> ConsumptionVersion | None: # --------------------------------------------------------------------------- -class ConfigObjectEntity(_FrozenModel): +class ConfigurationObjectEntity(_FrozenModel): """One entity listed under a config object by the ``configurationObjects`` API. Attributes: @@ -96,26 +83,26 @@ class ConfigObjectEntity(_FrozenModel): entity_id: str = Field(alias="entityId") -class ConfigObjectEntry(_FrozenModel): +class ConfigurationObjectEntry(_FrozenModel): """One config object and its entities as returned by the API. Attributes: - config_object_id: Authored config object identifier (e.g. ``"payment-config"``). + configuration_object_id: Authored config object identifier (e.g. ``"payment-config"``). entities: Entities belonging to this config object. """ - config_object_id: str = Field(alias="configurationObjectId") - entities: list[ConfigObjectEntity] + configuration_object_id: str = Field(alias="configurationObjectId") + entities: list[ConfigurationObjectEntity] -class ConfigObjectList(_FrozenModel): +class ConfigurationObjectList(_FrozenModel): """Config objects for a consumption version, already grouped by the API. Attributes: - items: List of :class:`ConfigObjectEntry` objects. + items: List of :class:`ConfigurationObjectEntry` objects. """ - items: list[ConfigObjectEntry] + items: list[ConfigurationObjectEntry] class EntityData: @@ -172,7 +159,7 @@ def __repr__(self) -> str: @dataclass -class ConfigEntity: +class ConfigurationEntity: """A single entity within a config object. Attributes: @@ -185,16 +172,16 @@ class ConfigEntity: @dataclass -class ConfigObject: +class ConfigurationObject: """A configuration object and its entities. Attributes: - config_object_id: Authored config object identifier (e.g. ``"payment-config"``). + configuration_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[ConfigEntity] + configuration_object_id: str + entities: list[ConfigurationEntity] def get_entity_data(self, entity_id: str) -> EntityData | None: """Return the data for the entity with the given ID. @@ -210,50 +197,52 @@ def get_entity_data(self, entity_id: str) -> EntityData | None: @dataclass -class ConfigData: +class ConfigurationData: """Complete business configuration — all config objects for one consumption version. Attributes: consumption_version: Version this data was fetched from. app_tenant_id: Application tenant this data belongs to. - config_objects: Configuration objects and their entity data. + configuration_objects: Configuration objects and their entity data. """ consumption_version: str app_tenant_id: str - config_objects: list[ConfigObject] + configuration_objects: list[ConfigurationObject] - def get_config_object(self, config_object_id: str) -> ConfigObject | None: + def get_configuration_object( + self, configuration_object_id: str + ) -> ConfigurationObject | None: """Return the config object with the given ID. Args: - config_object_id: Authored config object identifier. + configuration_object_id: Authored config object identifier. Returns: - Matching :class:`ConfigObject`, or ``None`` if not found. + Matching :class:`ConfigurationObject`, or ``None`` if not found. """ return next( ( co - for co in self.config_objects - if co.config_object_id == config_object_id + for co in self.configuration_objects + if co.configuration_object_id == configuration_object_id ), None, ) def get_entity_data( - self, config_object_id: str, entity_id: str + self, configuration_object_id: str, entity_id: str ) -> EntityData | None: """Return the data for the given config object and entity IDs. Args: - config_object_id: Authored config object identifier. + configuration_object_id: Authored config object identifier. entity_id: Authored entity identifier. Returns: :class:`EntityData` for the matching entity, or ``None`` if not found. """ - co = self.get_config_object(config_object_id) + co = self.get_configuration_object(configuration_object_id) return co.get_entity_data(entity_id) if co is not None else None diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index 96c524b0..fa7ceea5 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -38,10 +38,10 @@ from sap_cloud_sdk.cbc._models import ( ApiError, - ConfigData, - ConfigEntity, - ConfigObject, - ConfigObjectList, + ConfigurationData, + ConfigurationEntity, + ConfigurationObject, + ConfigurationObjectList, ConsumptionVersions, EntityData, ) @@ -261,7 +261,7 @@ def _get_consumption_versions( def _get_configuration_objects( self, base_url: str, app_tenant_id: str, consumption_version: str - ) -> ConfigObjectList: + ) -> ConfigurationObjectList: """Return the config objects (with their entities) for the given version. The API returns config objects already grouped with their child entities, @@ -273,7 +273,7 @@ def _get_configuration_objects( consumption_version: Consumption version ID. Returns: - :class:`ConfigObjectList` containing config objects and their entities. + :class:`ConfigurationObjectList` containing config objects and their entities. Raises: CBCClientError: On 4xx responses. @@ -285,13 +285,13 @@ def _get_configuration_objects( f"/consumptionVersions/{consumption_version}/configurationObjects" f"?appTenantId={app_tenant_id}", ) - return ConfigObjectList.model_validate(self._request("GET", url).json()) + return ConfigurationObjectList.model_validate(self._request("GET", url).json()) @record_metrics(Module.CBC, Operation.CBC_GET_CONFIGURATION) def get_configuration( self, consumption_version: str | None = None, - ) -> ConfigData: + ) -> ConfigurationData: """Return the full business configuration for the configured tenant. Fetches the config objects and the data for every entity they contain, for @@ -304,7 +304,7 @@ def get_configuration( latest version is resolved via :meth:`get_consumption_versions`. Returns: - :class:`ConfigData` containing all entity data for the version. + :class:`ConfigurationData` containing all entity data for the version. Raises: CBCClientError: On 4xx responses, or when no consumption version exists @@ -326,15 +326,15 @@ def get_configuration( co_list = self._get_configuration_objects( base_url, app_tenant_id, consumption_version ) - config_objects = [ - ConfigObject( - config_object_id=entry.config_object_id, + configuration_objects = [ + ConfigurationObject( + configuration_object_id=entry.configuration_object_id, entities=[ self._fetch_entity_data( base_url, app_tenant_id, consumption_version, - entry.config_object_id, + entry.configuration_object_id, entity.entity_id, ) for entity in entry.entities @@ -342,16 +342,16 @@ def get_configuration( ) for entry in co_list.items ] - return ConfigData( + return ConfigurationData( consumption_version=consumption_version, app_tenant_id=app_tenant_id, - config_objects=config_objects, + configuration_objects=configuration_objects, ) @record_metrics(Module.CBC, Operation.CBC_GET_ENTITY_DATA) def get_entity_data( self, - config_object_id: str, + configuration_object_id: str, entity_id: str, consumption_version: str | None = None, ) -> EntityData: @@ -362,7 +362,7 @@ def get_entity_data( config object. Args: - config_object_id: Authored config object identifier (e.g. ``"payment-config"``). + configuration_object_id: Authored config object identifier (e.g. ``"payment-config"``). entity_id: Authored entity identifier (e.g. ``"payment-mode"``). consumption_version: Consumption version ID. When ``None``, the latest version is resolved via :meth:`get_consumption_versions`. @@ -387,7 +387,11 @@ def get_entity_data( ) consumption_version = latest.version return self._fetch_entity_data( - base_url, app_tenant_id, consumption_version, config_object_id, entity_id + base_url, + app_tenant_id, + consumption_version, + configuration_object_id, + entity_id, ).data def _fetch_entity_data( @@ -395,13 +399,13 @@ def _fetch_entity_data( base_url: str, app_tenant_id: str, consumption_version: str, - config_object_id: str, + configuration_object_id: str, entity_id: str, - ) -> ConfigEntity: + ) -> ConfigurationEntity: url = self._configurations_url( base_url, f"/consumptionVersions/{consumption_version}/configurationObjects" - f"/{config_object_id}/entities/{entity_id}/data" + f"/{configuration_object_id}/entities/{entity_id}/data" f"?appTenantId={app_tenant_id}", ) body = self._request("GET", url).json() @@ -414,7 +418,7 @@ def _fetch_entity_data( # ARRAY / UNSPECIFIED / absent — items may be absent or empty. raw_data = content.get("items", []) - return ConfigEntity(entity_id=entity_id, data=EntityData(raw_data)) + return ConfigurationEntity(entity_id=entity_id, data=EntityData(raw_data)) def _configurations_url(self, base_url: str, path: str = "") -> str: base = base_url.rstrip("/") diff --git a/src/sap_cloud_sdk/cbc/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index fcba1db7..85bd757c 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -60,23 +60,23 @@ cv = versions.latest() # or versions.items[0], or your own selection logic config = cbc_client.get_configuration(consumption_version=cv.version) ``` -### ConfigData structure +### ConfigurationData structure ``` -ConfigData +ConfigurationData ├── consumption_version: str # e.g. "a0392d4f-72a9-..." ├── app_tenant_id: str -└── config_objects: list[ConfigObject] - ├── ConfigObject - │ ├── config_object_id: str # e.g. "payment-config" - │ └── entities: list[ConfigEntity] - │ └── ConfigEntity +└── configuration_objects: list[ConfigurationObject] + ├── ConfigurationObject + │ ├── configuration_object_id: str # e.g. "payment-config" + │ └── entities: list[ConfigurationEntity] + │ └── ConfigurationEntity │ ├── entity_id: str # e.g. "payment-mode" │ └── data: EntityData # .as_list() or .as_object() - └── ConfigObject - ├── config_object_id: str # e.g. "agent-config" - └── entities: list[ConfigEntity] - └── ConfigEntity + └── ConfigurationObject + ├── configuration_object_id: str # e.g. "agent-config" + └── entities: list[ConfigurationEntity] + └── ConfigurationEntity ├── entity_id: str # e.g. "contact" └── data: EntityData # .as_list() or .as_object() ``` @@ -84,23 +84,23 @@ ConfigData ### Iterate all config objects and entities ```python -for co in config.config_objects: +for co in config.configuration_objects: for ed in co.entities: - print(f"{co.config_object_id}/{ed.entity_id}") + print(f"{co.configuration_object_id}/{ed.entity_id}") ``` ### Look up a specific entity ```python -# From already-fetched ConfigData +# From already-fetched ConfigurationData modes = config.get_entity_data("payment-config", "payment-mode") # EntityData | None if modes: for row in modes.as_list(): print(row["paymentModeCode"], row["name"]) ``` -If you need all entities for one config object, use `get_config_object` to get the -`ConfigObject` and iterate its `entities` list. +If you need all entities for one config object, use `get_configuration_object` to get the +`ConfigurationObject` and iterate its `entities` list. `modes.as_list()` returns `list[dict]` and raises `ValueError` if the data is not a list. `modes.as_object()` returns `dict` and raises `ValueError` if the data is not a dict. @@ -274,12 +274,15 @@ from sap_cloud_sdk import cbc class StubCBCClient: def get_consumption_versions(self): ... def get_configuration(self, consumption_version=None): - return cbc.ConfigData( + return cbc.ConfigurationData( consumption_version="cv1", app_tenant_id="app-t1", - config_objects=[], + configuration_objects=[], ) - def get_entity_data(self, config_object_id, entity_id, consumption_version=None): ... + + def get_entity_data( + self, configuration_object_id, entity_id, consumption_version=None + ): ... def test_my_service(): diff --git a/tests/cbc/integration/cbc.feature b/tests/cbc/integration/cbc.feature index 6bb8154c..fbbae6b4 100644 --- a/tests/cbc/integration/cbc.feature +++ b/tests/cbc/integration/cbc.feature @@ -15,9 +15,9 @@ Feature: CBC (Central Business Configuration) Integration # ── Configuration ──────────────────────────────────────────────────────────── - Scenario: Fetch full configuration returns ConfigData + Scenario: Fetch full configuration returns ConfigurationData When I call get_configuration - Then the result should be a ConfigData with a non-empty consumption_version + Then the result should be a ConfigurationData with a non-empty consumption_version And the app_tenant_id should match the configured tenant Scenario: Full configuration contains at least one config object diff --git a/tests/cbc/integration/conftest.py b/tests/cbc/integration/conftest.py index 95a9e39a..fc1294e4 100644 --- a/tests/cbc/integration/conftest.py +++ b/tests/cbc/integration/conftest.py @@ -88,7 +88,5 @@ def cbc_client() -> CBCClient: if landscape: os.environ.setdefault("APPFND_CONHOS_LANDSCAPE", landscape) - set_context( - RuntimeContext({APP_TENANT_ID: app_tid, DWC_SUBDOMAIN: subdomain}) - ) + set_context(RuntimeContext({APP_TENANT_ID: app_tid, DWC_SUBDOMAIN: subdomain})) return create_client() diff --git a/tests/cbc/integration/test_e2e_bdd.py b/tests/cbc/integration/test_e2e_bdd.py index 03610f16..99d40c7e 100644 --- a/tests/cbc/integration/test_e2e_bdd.py +++ b/tests/cbc/integration/test_e2e_bdd.py @@ -20,7 +20,7 @@ import pytest from pytest_bdd import given, scenario, then, when -from sap_cloud_sdk.cbc import ConfigData, ConsumptionVersions, EntityData +from sap_cloud_sdk.cbc import ConfigurationData, ConsumptionVersions, EntityData from sap_cloud_sdk.cbc.client import CBCClient pytestmark = pytest.mark.integration @@ -47,13 +47,13 @@ def test_latest_version_non_empty(): pass -@scenario("cbc.feature", "Fetch full configuration returns ConfigData") -def test_get_configuration_returns_config_data(): +@scenario("cbc.feature", "Fetch full configuration returns ConfigurationData") +def test_get_configuration_returns_configuration_data(): pass @scenario("cbc.feature", "Full configuration contains at least one config object") -def test_configuration_has_config_objects(): +def test_configuration_has_configuration_objects(): pass @@ -104,39 +104,39 @@ def assert_latest_version_non_empty(ctx: dict): 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) +@then("the result should be a ConfigurationData with a non-empty consumption_version") +def assert_configuration_data_type(ctx: dict): + config: ConfigurationData = ctx["config"] + assert isinstance(config, ConfigurationData) assert config.consumption_version @then("the app_tenant_id should match the configured tenant") def assert_app_tenant_id(ctx: dict, cbc_app_tenant_id: str): - config: ConfigData = ctx["config"] + config: ConfigurationData = ctx["config"] assert config.app_tenant_id == cbc_app_tenant_id @then("the result should contain at least one config object") -def assert_config_objects_non_empty(ctx: dict): - config: ConfigData = ctx["config"] - assert len(config.config_objects) >= 1 +def assert_configuration_objects_non_empty(ctx: dict): + config: ConfigurationData = ctx["config"] + assert len(config.configuration_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: + config: ConfigurationData = ctx["config"] + for co in config.configuration_objects: for ed in co.entities: assert ed.entity_id, ( - f"entity_id missing in config_object={co.config_object_id!r}" + f"entity_id missing in configuration_object={co.configuration_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: + config: ConfigurationData = ctx["config"] + for co in config.configuration_objects: for ed in co.entities: raw = ed.data try: @@ -149,12 +149,16 @@ def assert_entity_data_accessible(ctx: dict): @when("I call get_entity_data for the first entity in the first config object") def call_get_entity_data(ctx: dict, cbc_client: CBCClient): - config: ConfigData = cbc_client.get_configuration() - assert config.config_objects, "no config objects — cannot test get_entity_data" - co = config.config_objects[0] - assert co.entities, f"no entities in {co.config_object_id!r}" + config: ConfigurationData = cbc_client.get_configuration() + assert config.configuration_objects, ( + "no config objects — cannot test get_entity_data" + ) + co = config.configuration_objects[0] + assert co.entities, f"no entities in {co.configuration_object_id!r}" entity_id = co.entities[0].entity_id - ctx["entity_data"] = cbc_client.get_entity_data(co.config_object_id, entity_id) + ctx["entity_data"] = cbc_client.get_entity_data( + co.configuration_object_id, entity_id + ) @then("the result should be EntityData accessible as a list or object") diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index 5e19318f..e5b529c1 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -19,7 +19,7 @@ CBCNetworkError, CBCServerError, ) -from sap_cloud_sdk.cbc._models import ConfigData, EntityData +from sap_cloud_sdk.cbc._models import ConfigurationData, EntityData # --------------------------------------------------------------------------- @@ -134,7 +134,9 @@ def test_get_consumption_versions_on_500_raises_cbc_server_error(self): with pytest.raises(CBCServerError): client.get_consumption_versions() - def test_get_consumption_versions_on_connection_failure_raises_cbc_network_error(self): + def test_get_consumption_versions_on_connection_failure_raises_cbc_network_error( + self, + ): client, mock_http = _make_client() mock_http.request.side_effect = httpx.ConnectError("refused") with pytest.raises(CBCNetworkError): @@ -147,7 +149,9 @@ def test_get_consumption_versions_on_connection_failure_raises_cbc_network_error class TestGetConfigurationObjects: - def test_get_configuration_objects_with_multiple_objects_returns_grouped_config_objects(self): + def test_get_configuration_objects_with_multiple_objects_returns_grouped_configuration_objects( + self, + ): client, mock_http = _make_client() mock_http.request.return_value = _mock_response( json_body={ @@ -170,7 +174,7 @@ def test_get_configuration_objects_with_multiple_objects_returns_grouped_config_ "https://cbc.example.ondemand.com", "app-tenant", "cv1" ) assert len(result.items) == 2 - assert result.items[0].config_object_id == "payment-config" + assert result.items[0].configuration_object_id == "payment-config" assert [e.entity_id for e in result.items[1].entities] == [ "tax-category", "tax-rate", @@ -240,16 +244,21 @@ def test_fetch_entity_data_with_absent_content_defaults_to_empty_list(self): class TestGetConfiguration: - def test_get_configuration_without_version_resolves_latest_and_returns_config_data(self): + def test_get_configuration_without_version_resolves_latest_and_returns_configuration_data( + self, + ): client, mock_http = _make_client() versions_response = _mock_response(json_body={"items": [{"version": "v2"}]}) - config_objects_response = _mock_response(json_body={"items": []}) - mock_http.request.side_effect = [versions_response, config_objects_response] + configuration_objects_response = _mock_response(json_body={"items": []}) + mock_http.request.side_effect = [ + versions_response, + configuration_objects_response, + ] result = client.get_configuration() - assert isinstance(result, ConfigData) + assert isinstance(result, ConfigurationData) assert result.consumption_version == "v2" - assert result.config_objects == [] + assert result.configuration_objects == [] def test_get_configuration_when_no_versions_exist_raises_cbc_client_error(self): client, mock_http = _make_client() @@ -259,7 +268,7 @@ def test_get_configuration_when_no_versions_exist_raises_cbc_client_error(self): def test_get_configuration_with_explicit_version_skips_version_resolution(self): client, mock_http = _make_client() - config_objects_response = _mock_response( + configuration_objects_response = _mock_response( json_body={ "items": [ { @@ -275,13 +284,15 @@ def test_get_configuration_with_explicit_version_skips_version_resolution(self): "content": {"items": [{"k": "v"}]}, } ) - mock_http.request.side_effect = [config_objects_response, data_response] + mock_http.request.side_effect = [configuration_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" + assert len(result.configuration_objects) == 1 + assert ( + result.configuration_objects[0].configuration_object_id == "payment-config" + ) + assert len(result.configuration_objects[0].entities) == 1 + assert result.configuration_objects[0].entities[0].entity_id == "payment-mode" def test_get_configuration_with_multiple_entities_resolves_base_url_once(self): """One get_configuration resolves base_url once, not per HTTP request.""" @@ -298,7 +309,7 @@ def base_url_fn() -> str: app_tenant_id=_app_tid(), http_client=mock_http, ) - config_objects_response = _mock_response( + configuration_objects_response = _mock_response( json_body={ "items": [ { @@ -316,7 +327,7 @@ def base_url_fn() -> str: ) # config-objects + 2 entity-data requests = 3 HTTP calls, 1 base_url resolve mock_http.request.side_effect = [ - config_objects_response, + configuration_objects_response, data_response, data_response, ] @@ -325,14 +336,15 @@ def base_url_fn() -> str: assert call_count == 1 - # --------------------------------------------------------------------------- # CBCClient — get_entity_data # --------------------------------------------------------------------------- class TestGetEntityData: - def test_get_entity_data_without_version_resolves_latest_and_returns_entity_data(self): + def test_get_entity_data_without_version_resolves_latest_and_returns_entity_data( + self, + ): client, mock_http = _make_client() versions_response = _mock_response(json_body={"items": [{"version": "v1"}]}) entity_response = _mock_response( @@ -390,7 +402,9 @@ def test_context_manager_on_exit_closes_http_client(self): class TestCreateClient: - def test_create_client_with_base_url_and_app_tenant_id_overrides_returns_client(self): + def test_create_client_with_base_url_and_app_tenant_id_overrides_returns_client( + self, + ): client = create_client( CBCConfig( base_url="http://localhost:8001", @@ -428,7 +442,6 @@ def test_create_client_with_ssl_context_override_passes_it_through(self): assert factory() is ctx - # --------------------------------------------------------------------------- # _is_tls_failure # --------------------------------------------------------------------------- @@ -496,7 +509,9 @@ def build() -> httpx.Client: client._build_http_client = build # ty: ignore[invalid-assignment] return client, built - def test_cert_rotation_on_tls_failure_rebuilds_client_and_retries_successfully(self): + def test_cert_rotation_on_tls_failure_rebuilds_client_and_retries_successfully( + self, + ): calls = {"factory": 0} def factory() -> ssl.SSLContext: @@ -572,7 +587,9 @@ def test_cert_rotation_without_ssl_factory_does_not_rebuild_client(self): # one attempt only, no rebuild path mock_http.request.assert_called_once() - def test_cert_rotation_when_factory_fails_propagates_error_and_retains_old_client(self): + def test_cert_rotation_when_factory_fails_propagates_error_and_retains_old_client( + self, + ): calls = {"factory": 0} def factory() -> ssl.SSLContext: diff --git a/tests/cbc/unit/test_create_client.py b/tests/cbc/unit/test_create_client.py index f57bc7b0..7c5c90a2 100644 --- a/tests/cbc/unit/test_create_client.py +++ b/tests/cbc/unit/test_create_client.py @@ -109,7 +109,9 @@ def test_resolve_base_url_with_no_matching_fragment_raises_cbc_config_error(self with pytest.raises(CBCConfigError, match="No CBC mapping fragment"): _resolve_base_url("default") - def test_resolve_base_url_with_fragment_missing_cbc_url_raises_cbc_config_error(self): + def test_resolve_base_url_with_fragment_missing_cbc_url_raises_cbc_config_error( + self, + ): _seed_context(app_tenant_id="app-t1", subdomain="appfnd-subscriber") match = MagicMock() match.name = f"{CBC_FRAGMENT_PREFIX}appfnd-subscriber" @@ -201,7 +203,9 @@ def test_load_ssl_context_when_destination_raises_wraps_as_cbc_config_error(self class TestCreateClientDefaults: - def test_default_wiring_with_landscape_env_builds_ssl_context_from_cert(self, monkeypatch): + def test_default_wiring_with_landscape_env_builds_ssl_context_from_cert( + self, monkeypatch + ): monkeypatch.setenv(ENV_LANDSCAPE, "cbc-fndtst-dev-eu12") with patch( "sap_cloud_sdk.cbc.client._load_ssl_context", @@ -231,7 +235,7 @@ def test_env_overrides_apply_instance_and_cert_name(self, monkeypatch): assert instance_arg == "cbc-instance" assert cert_arg == "my-cert.pem" - def test_config_object_applies_instance_and_cert_name(self): + def test_configuration_object_applies_instance_and_cert_name(self): with patch( "sap_cloud_sdk.cbc.client._load_ssl_context", return_value=ssl.create_default_context(), diff --git a/tests/cbc/unit/test_models.py b/tests/cbc/unit/test_models.py index bfbb746e..20af0c3a 100644 --- a/tests/cbc/unit/test_models.py +++ b/tests/cbc/unit/test_models.py @@ -8,11 +8,11 @@ from sap_cloud_sdk.cbc._models import ( ApiError, - ConfigData, - ConfigObject, + ConfigurationData, + ConfigurationObject, ConsumptionVersion, ConsumptionVersions, - ConfigEntity, + ConfigurationEntity, EntityData, ) @@ -111,43 +111,47 @@ def test_value_with_any_content_returns_raw_without_shape_assertion(self): # --------------------------------------------------------------------------- -# ConfigData helpers +# ConfigurationData helpers # --------------------------------------------------------------------------- -class TestConfigData: - def _entity_data(self, entity_id: str) -> ConfigEntity: - return ConfigEntity(entity_id=entity_id, data=EntityData([{"id": entity_id}])) +class TestConfigurationData: + def _entity_data(self, entity_id: str) -> ConfigurationEntity: + return ConfigurationEntity( + entity_id=entity_id, data=EntityData([{"id": entity_id}]) + ) - def _config_object(self, config_object_id: str, *entity_ids: str) -> ConfigObject: - return ConfigObject( - config_object_id=config_object_id, + def _configuration_object( + self, configuration_object_id: str, *entity_ids: str + ) -> ConfigurationObject: + return ConfigurationObject( + configuration_object_id=configuration_object_id, entities=[self._entity_data(eid) for eid in entity_ids], ) - def _config(self, *config_objects: ConfigObject) -> ConfigData: - return ConfigData( + def _config(self, *configuration_objects: ConfigurationObject) -> ConfigurationData: + return ConfigurationData( consumption_version="cv1", app_tenant_id="app-t1", - config_objects=list(config_objects), + configuration_objects=list(configuration_objects), ) - def test_get_config_object_with_matching_id_returns_config_object(self): + def test_get_configuration_object_with_matching_id_returns_configuration_object(self): config = self._config( - self._config_object("ObjA", "E1"), - self._config_object("ObjB", "E2"), + self._configuration_object("ObjA", "E1"), + self._configuration_object("ObjB", "E2"), ) - result = config.get_config_object("ObjA") + result = config.get_configuration_object("ObjA") assert result is not None - assert result.config_object_id == "ObjA" + assert result.configuration_object_id == "ObjA" - def test_get_config_object_with_missing_id_returns_none(self): - config = self._config(self._config_object("ObjA", "E1")) - assert config.get_config_object("Missing") is None + def test_get_configuration_object_with_missing_id_returns_none(self): + config = self._config(self._configuration_object("ObjA", "E1")) + assert config.get_configuration_object("Missing") is None def test_get_entity_data_with_matching_ids_returns_correct_entity_data(self): config = self._config( - self._config_object("ObjA", "E1", "E2"), + self._configuration_object("ObjA", "E1", "E2"), ) result = config.get_entity_data("ObjA", "E2") assert result is not None @@ -155,7 +159,7 @@ def test_get_entity_data_with_matching_ids_returns_correct_entity_data(self): assert result.as_list() == [{"id": "E2"}] def test_get_entity_data_with_missing_entity_id_returns_none(self): - config = self._config(self._config_object("ObjA", "E1")) + config = self._config(self._configuration_object("ObjA", "E1")) assert config.get_entity_data("ObjA", "Missing") is None From 118e6a4a6aaee7da74f65fd5917762ddbd9075c8 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Thu, 15 Oct 2026 14:45:41 +0530 Subject: [PATCH 23/26] fix(cbc): sort Module.CBC into alphabetical position in telemetry enum --- src/sap_cloud_sdk/core/telemetry/module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/core/telemetry/module.py b/src/sap_cloud_sdk/core/telemetry/module.py index 840e6ac5..2c64727d 100644 --- a/src/sap_cloud_sdk/core/telemetry/module.py +++ b/src/sap_cloud_sdk/core/telemetry/module.py @@ -7,13 +7,13 @@ class Module(str, Enum): """SDK module identifiers for telemetry.""" ADMS = "adms" - CBC = "cbc" AGENT_MEMORY = "agent_memory" AGENTGATEWAY = "agentgateway" AICORE = "aicore" AUDITLOG = "auditlog" AUDITLOG_NG = "auditlog_ng" BOOTSTRAP = "bootstrap" + CBC = "cbc" DATA_ANONYMIZATION = "data_anonymization" DESTINATION = "destination" DPI_NG = "dpi_ng" From 004d264f836ff5224a0b623feb2ff68053fa1a6c Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Thu, 15 Oct 2026 14:45:41 +0530 Subject: [PATCH 24/26] docs(cbc): add Multi-tenancy section to the user guide --- src/sap_cloud_sdk/cbc/user-guide.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/sap_cloud_sdk/cbc/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index 85bd757c..ca5fdbd4 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -208,6 +208,24 @@ mTLS context from a freshly-fetched certificate and retries the request once. A long-lived `create_client()` singleton therefore recovers from rotation on its own — no need to recreate it. +## Multi-tenancy + +- **Supported:** Yes +- **Authentication:** IAS (the `app_tid` claim) for tenant identity; provider-level + mTLS to CBC. The subscriber subdomain selects the tenant's CBC endpoint. +- **How to use:** Wire `bootstrap(app)` once at startup, then call + `create_client()` with no arguments. CBC is resolved per request from the + SDK-wide runtime context: the application tenant id (`APP_TENANT_ID`, the IAS + `app_tid` claim) is sent as the `appTenantId` query parameter, and the + subscriber subdomain (`DWC_SUBDOMAIN`) selects the tenant-mapping Destination + Fragment (`CBC_TenantMapping_`, fetched with + `AccessStrategy.SUBSCRIBER_ONLY`) whose `cbcUrl` is the per-tenant base URL. A + single client instance is safe to share across tenants and threads — nothing + tenant-specific is held on the client; each call reads the current request's + tenant from the runtime context. For local development or pro-code callers, + override `base_url` / `app_tenant_id` on `CBCConfig` (see [Overrides](#overrides)). +- **Further reading:** [Platform setup](#platform-setup); `sap_cloud_sdk.core.runtime_context`. + ## Overrides `create_client` accepts an optional `CBCConfig`. Any field left unset is inferred From 22e0dc667d1f41ae68dc3ef0c542cbd83c9c7b6b Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Thu, 15 Oct 2026 14:45:41 +0530 Subject: [PATCH 25/26] test(cbc): document CBC integration env vars in .env_integration_tests.example --- .env_integration_tests.example | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.env_integration_tests.example b/.env_integration_tests.example index 54e44a28..ebf9b605 100644 --- a/.env_integration_tests.example +++ b/.env_integration_tests.example @@ -39,6 +39,16 @@ AICORE_FILTER_TEST_SELF_HARM_PROMPT= CLOUD_SDK_CFG_AUDITLOG_DEFAULT_URL=https://your-auditlog-api-url-here CLOUD_SDK_CFG_AUDITLOG_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-client-id","clientsecret":"your-client-secret"}' +# CBC (Central Business Configuration) +# Resolves the CBC URL from the tenant-mapping Destination Fragment and the mTLS +# certificate from the Destination Service, so it also needs the DESTINATION +# binding vars below. The three CLOUD_SDK_CFG_CBC_DEFAULT_* values seed the SDK +# runtime context (app tenant id, subscriber subdomain) and derive the +# certificate name from the landscape. +CLOUD_SDK_CFG_CBC_DEFAULT_APP_TENANT_ID=your-app-tenant-id-here +CLOUD_SDK_CFG_CBC_DEFAULT_TENANT_SUBDOMAIN=your-subscriber-tenant-subdomain-here +CLOUD_SDK_CFG_CBC_DEFAULT_LANDSCAPE=your-landscape-here + # DATA ANONYMIZATION CLOUD_SDK_CFG_DATA_ANONYMIZATION_DEFAULT_URL=https://your-data-anonymization-api-url-here CLOUD_SDK_CFG_DATA_ANONYMIZATION_DEFAULT_CERT=your-base64-encoded-client-certificate-pem From 9ce7eec526d0729353a3a26b918772db667cf5af Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Thu, 15 Oct 2026 14:45:41 +0530 Subject: [PATCH 26/26] style(cbc): apply ruff format to test_models.py --- tests/cbc/unit/test_models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/cbc/unit/test_models.py b/tests/cbc/unit/test_models.py index 20af0c3a..5bfc9b90 100644 --- a/tests/cbc/unit/test_models.py +++ b/tests/cbc/unit/test_models.py @@ -136,7 +136,9 @@ def _config(self, *configuration_objects: ConfigurationObject) -> ConfigurationD configuration_objects=list(configuration_objects), ) - def test_get_configuration_object_with_matching_id_returns_configuration_object(self): + def test_get_configuration_object_with_matching_id_returns_configuration_object( + self, + ): config = self._config( self._configuration_object("ObjA", "E1"), self._configuration_object("ObjB", "E2"),