From fe8134d5b99cc3b215662242ae4f1e821aaef353 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Sun, 13 Sep 2026 23:12:17 +0530 Subject: [PATCH 1/4] 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 528618dd..aaed9c1a 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 7836e20e..f81258f4 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 fd0e0488a3112d328122fe646c869d81f79ce166 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Mon, 14 Sep 2026 19:39:23 +0530 Subject: [PATCH 2/4] 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 ced9539fabf8effe7254a838540a1d16ff24198b Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Mon, 14 Sep 2026 20:57:22 +0530 Subject: [PATCH 3/4] =?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 --- pyproject.toml | 2 +- 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 | 3 ++- tests/core/unit/telemetry/test_operation.py | 5 +++-- 9 files changed, 51 insertions(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2e750880..fce3e853 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.53.1" +version = "0.54.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" 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 f1a325e0..e8e0a16d 100644 --- a/tests/core/unit/telemetry/test_module.py +++ b/tests/core/unit/telemetry/test_module.py @@ -55,8 +55,9 @@ def test_module_in_collection(self): def test_all_modules_present(self): """Test that all expected modules are present.""" all_modules = list(Module) - assert len(all_modules) == 15 + 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 c9bdbeb2..73023897 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -216,5 +216,6 @@ def test_operation_count(self): all_operations = list(Operation) # 3 auditlog + 12 destination + 10 certificate + 10 fragment + 8 objectstore # + 2 extensibility + 7 aicore + 23 dms + 6 agentgateway + 13 agent_memory - # + 5 data_anonymization + 52 adms + 6 print + 1 bootstrap + 3 output_management = 161 - assert len(all_operations) == 161 + # + 5 data_anonymization + 52 adms + 6 print + 1 bootstrap + 3 output_management + # + 2 cbc = 163 + assert len(all_operations) == 163 From c3dc18a73ef10a9ed568d33c68b5fa84b73d01c3 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Wed, 16 Sep 2026 10:06:15 +0530 Subject: [PATCH 4/4] 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.