Skip to content

feat(cbc): add CBC (Central Business Configuration) client module - #333

Open
soumyadey wants to merge 4 commits into
SAP:mainfrom
soumyadey:feat/cbc-client
Open

soumyadey wants to merge 4 commits into
SAP:mainfrom
soumyadey:feat/cbc-client

Conversation

@soumyadey

@soumyadey soumyadey commented Sep 14, 2026

Copy link
Copy Markdown
Member

Description

Adds sap_cloud_sdk.cbc — a typed Python client for reading tenant-specific business configuration from SAP Central Business Configuration (CBC). Supports production (mTLS + subdomain-per-tenant URL routing), local mock servers (loopback auto-detection), with a full exception hierarchy, Pydantic-backed API models, and a CBCClient Protocol for test doubles.

Related Issue

Closes #280

Type of Change

  • New feature (non-breaking change that adds functionality)

How to Test

Unit tests (no external service required):

pytest tests/cbc/unit/

Integration tests (requires a CBC server or mock):

CLOUD_SDK_CBC_URL=http://localhost:8001 \
CLOUD_SDK_CBC_CBC_TENANT_ID=<cbc-tenant-id> \
CLOUD_SDK_CBC_APP_TENANT_ID=<app-tenant-id> \
pytest -v tests/cbc/integration/

Expected result: 51 unit tests pass; integration tests skip automatically when env vars are absent (CI-safe).

Checklist

  • I have read the Contributing Guidelines
  • I have verified that my changes solve the issue
  • I have added/updated automated tests to cover my changes
  • All tests pass locally
  • I have verified that my code follows the Code Guidelines
  • I have updated documentation (if applicable)
  • I have added type hints for all public APIs
  • My code does not contain sensitive information (credentials, tokens, etc.)
  • I have followed Conventional Commits for commit messages

Breaking Changes

None. This is a new module with no existing public API.

Additional Notes

Module structure follows the repo convention (client.py, config.py, exceptions.py, _models.py, py.typed, user-guide.md).

Key design decisions:

  • get_configuration groups the flat entity list from the API into ConfigObject buckets, so consumers work with the authored config-object vocabulary rather than raw entity lists.
  • EntityData, ConfigObject, ConfigData are plain @dataclass (not Pydantic) — they are constructed in client code, never parsed from JSON.
  • mTLS credentials can be supplied as file paths (CLOUD_SDK_CBC_CERT_PATH/KEY_PATH) or PEM values (CLOUD_SDK_CBC_CERT/KEY) for environments where secrets are injected as env vars rather than mounted files.
  • Only the two public API methods carry @record_metrics; internal helpers do not, to avoid double-counting a single user operation.

Test evidence:

51 passed, 1 warning in 9.41s
tests/cbc/unit/test_client.py::TestDefaultClientLocalMode::test_loopback_localhost_disables_subdomain_replacement PASSED
tests/cbc/unit/test_client.py::TestDefaultClientLocalMode::test_loopback_127_disables_subdomain_replacement PASSED
tests/cbc/unit/test_client.py::TestDefaultClientLocalMode::test_production_url_enables_subdomain_replacement PASSED
tests/cbc/unit/test_client.py::TestConfigurationsUrl::test_production_replaces_subdomain_with_tenant PASSED
tests/cbc/unit/test_client.py::TestConfigurationsUrl::test_local_does_not_replace_subdomain PASSED
tests/cbc/unit/test_client.py::TestGetConsumptionVersions::test_returns_parsed_versions PASSED
tests/cbc/unit/test_client.py::TestGetConsumptionVersions::test_raises_client_error_on_404 PASSED
tests/cbc/unit/test_client.py::TestGetConsumptionVersions::test_raises_server_error_on_500 PASSED
tests/cbc/unit/test_client.py::TestGetConsumptionVersions::test_raises_network_error_on_connection_failure PASSED
tests/cbc/unit/test_client.py::TestGetEntities::test_returns_parsed_entities PASSED
tests/cbc/unit/test_client.py::TestGetEntityData::test_returns_entity_data_with_entity_id PASSED
tests/cbc/unit/test_client.py::TestGetEntityData::test_uses_api_metadata_entity_name_when_present PASSED
tests/cbc/unit/test_client.py::TestGetEntityData::test_handles_flat_list_response PASSED
tests/cbc/unit/test_client.py::TestGetConfiguration::test_resolves_latest_version_when_none_given PASSED
tests/cbc/unit/test_client.py::TestGetConfiguration::test_raises_runtime_error_when_no_versions_exist PASSED
tests/cbc/unit/test_client.py::TestGetConfiguration::test_uses_explicit_consumption_version PASSED
tests/cbc/unit/test_client.py::TestDefaultClientContextManager::test_close_called_on_exit PASSED
tests/cbc/unit/test_client.py::TestCreateClient::test_raises_config_error_when_no_env_vars PASSED
tests/cbc/unit/test_client.py::TestCreateClient::test_returns_client_for_loopback_url PASSED
tests/cbc/unit/test_client.py::TestCreateClient::test_raises_config_error_for_incomplete_triplet PASSED
tests/cbc/unit/test_client.py::TestCreateClient::test_raises_config_error_for_missing_cert_file PASSED
tests/cbc/unit/test_client.py::TestCreateClient::test_returns_client_with_env_var_cert_triplet PASSED
tests/cbc/unit/test_client.py::TestCreateClient::test_accepts_explicit_config PASSED
tests/cbc/unit/test_config.py::TestLoadFromEnv::test_raises_when_no_env_vars PASSED
tests/cbc/unit/test_config.py::TestLoadFromEnv::test_returns_config_for_url_only PASSED
tests/cbc/unit/test_config.py::TestLoadFromEnv::test_returns_config_with_cert_triplet PASSED
tests/cbc/unit/test_config.py::TestLoadFromEnv::test_raises_for_incomplete_triplet PASSED
tests/cbc/unit/test_config.py::TestLoadFromEnv::test_raises_for_incomplete_cert_pem_pair PASSED
tests/cbc/unit/test_config.py::TestLoadFromEnv::test_returns_config_with_cert_pem_pair PASSED
tests/cbc/unit/test_config.py::TestLoadFromEnv::test_raises_for_missing_cert_file PASSED
tests/cbc/unit/test_config.py::TestReadEnvPath::test_returns_none_when_unset PASSED
tests/cbc/unit/test_config.py::TestReadEnvPath::test_returns_path_when_file_exists PASSED
tests/cbc/unit/test_config.py::TestReadEnvPath::test_raises_when_file_missing PASSED
tests/cbc/unit/test_models.py::TestTenantContext::test_accepts_camel_case_aliases PASSED
tests/cbc/unit/test_models.py::TestTenantContext::test_accepts_snake_case_names PASSED
tests/cbc/unit/test_models.py::TestTenantContext::test_rejects_empty_cbc_tenant_id PASSED
tests/cbc/unit/test_models.py::TestConsumptionVersionsLatest::test_returns_none_for_empty_list PASSED
tests/cbc/unit/test_models.py::TestConsumptionVersionsLatest::test_returns_latest_by_modified_date PASSED
tests/cbc/unit/test_models.py::TestConsumptionVersionsLatest::test_returns_latest_by_created_date_when_no_modified PASSED
tests/cbc/unit/test_models.py::TestConsumptionVersionsLatest::test_returns_last_item_when_no_dates PASSED
tests/cbc/unit/test_models.py::TestEntityContent::test_as_list_returns_list PASSED
tests/cbc/unit/test_models.py::TestEntityContent::test_as_list_raises_when_dict PASSED
tests/cbc/unit/test_models.py::TestEntityContent::test_as_object_returns_dict PASSED
tests/cbc/unit/test_models.py::TestEntityContent::test_as_object_raises_when_list PASSED
tests/cbc/unit/test_models.py::TestConfigData::test_get_config_object_returns_matching PASSED
tests/cbc/unit/test_models.py::TestConfigData::test_get_config_object_returns_none_when_missing PASSED
tests/cbc/unit/test_models.py::TestConfigData::test_get_entity_data_returns_match PASSED
tests/cbc/unit/test_models.py::TestConfigData::test_get_entity_data_returns_none_when_missing PASSED
tests/cbc/unit/test_models.py::TestApiError::test_parses_cbc_error_envelope PASSED
tests/cbc/unit/test_models.py::TestApiError::test_fallback_on_empty_body PASSED
tests/cbc/unit/test_models.py::TestApiError::test_fallback_on_unparseable_body PASSED
Integration: 5 passed in 19.10s (real CBC server)
platform darwin -- Python 3.12.12, pytest-9.1.0
plugins: asyncio-1.4.0, bdd-8.1.0, respx-0.23.1

tests/cbc/integration/test_e2e_bdd.py::test_consumption_versions_non_empty PASSED
tests/cbc/integration/test_e2e_bdd.py::test_latest_version_non_empty PASSED
tests/cbc/integration/test_e2e_bdd.py::test_get_configuration_returns_config_data PASSED
tests/cbc/integration/test_e2e_bdd.py::test_configuration_has_config_objects PASSED
tests/cbc/integration/test_e2e_bdd.py::test_every_entity_has_id_and_data PASSED

5 passed, 1 warning in 19.10s
Sample ConfigData response (real CBC server)
{
  "consumption_version": "a0392d4f-...",
  "tenant_context": {
    "cbc_tenant_id": "<cbc-tenant-id>",
    "app_tenant_id": "<app-tenant-id>"
  },
  "config_objects": [
    {
      "config_object_id": "payment-config",
      "entities": [
        {
          "entity_id": "payment-mode",
          "data": [
            { "paymentModeCode": "CASH",          "name": "Cash",                "isOnline": false },
            { "paymentModeCode": "CARD",          "name": "Credit / Debit Card", "isOnline": false },
            { "paymentModeCode": "DIGITAL_WALLET","name": "Digital Wallet",      "isOnline": true  }
          ]
        }
      ]
    },
    {
      "config_object_id": "tax-config",
      "entities": [
        {
          "entity_id": "tax-category",
          "data": [
            { "code": "STD",     "ratePercent": 8.5, "isDefault": true  },
            { "code": "REDUCED", "ratePercent": 5,   "isDefault": false },
            { "code": "ZERO",    "ratePercent": 0,   "isDefault": false }
          ]
        }
      ]
    }
  ]
}

@soumyadey
soumyadey requested a review from a team as a code owner September 14, 2026 15:10

def get_configuration(
self,
tenant_context: TenantContext,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if tenant_context is required for all request, can we move it to client_level? Similar to agent gateway.

Also, can't we infer it from what is created during provisioning / spii?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback. You're right that binding tenant context at client level is the better pattern — it's exactly what agentgateway does with tenant_subdomain: str | Callable[[], str]. The callable form is the key: one injected client instance, and the callable reads from whatever auth context the consumer maintains at call time (e.g. a request-scoped context var populated during SPII handling).

The TenantContext | Callable[[], TenantContext] signature handles both agent deployment modes:

  • Single-tenant: tenant IDs are fixed at startup → pass a TenantContext value directly.
  • Multi-tenant: tenant IDs vary per request → pass a callable that reads from the incoming request context.

One nuance worth aligning on: CBC requires two IDs per call — cbcTenantId (for URL subdomain routing) and appTenantId (query param). The app tenant is extractable from the auth context, but cbcTenantId comes from a separate mapping populated during SPII provisioning.

On inferring from SPII: the client can't own or infer that mapping — the SPII callback handler stores it wherever the consumer decides (a cache, a DB, a context var), and the client has no business coupling to that store. The consumer extracts both IDs and supplies them via the callable. The proposal would be:

client = create_client(
    tenant_context=lambda: TenantContext(
        cbc_tenant_id=get_cbc_tid(auth_ctx.tenant_id),
        app_tenant_id=auth_ctx.tenant_id,
    )
)
# then all call sites become:
config = client.get_configuration()

Does that match your expectation? If so I'll update DefaultClient.__init__ to accept tenant_context: TenantContext | Callable[[], TenantContext] and remove it from the public method signatures — same pattern as agw's tenant_subdomain.

Comment thread src/sap_cloud_sdk/cbc/client.py Outdated

Example (local mock)::

client = DefaultClient(base_url="http://localhost:8001")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have a final decision about local mode and we would like to keep it consistent across module. Is this really needed on first version?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — on reflection this isn't needed. Quick context on why local mode exists: CBC rewrites the URL subdomain to the cbcTenantId on every request. When an agent tests against a locally running mock server (the CBC CLI's local cell command spins one up based on the agent's config object shapes), that rewrite silently corrupts the URL — http://localhost:8001 becomes http://<tenant-id>.localhost:8001, which won't resolve. Auto-detection was added to spare developers from having to know this.

But CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false alongside CLOUD_SDK_CBC_URL=http://localhost:8001 already handles it — so auto-detection is a convenience, not a necessity. Happy to remove it in v1 and revisit as part of the cross-module local mode decision. Shall I go ahead and remove it?

Comment thread src/sap_cloud_sdk/cbc/client.py Outdated
TenantContext(cbcTenantId="t1", appTenantId="app-t1")
)

Example (production)::

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we should have only create_client, why this is documented here?

We could also have a create_client receiving a config object for supporting more use cases in future. Check how other modules are doing it.

@soumyadey soumyadey Sep 16, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — fixed in c3dc18a. Removed the contradictory direct-instantiation production example; create_client is now the only documented path. Note: if the client-level tenant_context suggestion is accepted, the docstring examples will be updated in the same change.

replace_subdomain: bool | None = None


def load_from_env() -> CBCConfig:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this is only loading from env? This is not being provisioned by managed runtime. My expectation is that it should work similar to agw, where we read fragments and destination created during provisioning.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — I see that aicore supports both:

  1. Destination mode: AICORE_DESTINATION_NAME set → fetches URL + credentials from BTP Destination Service at startup.
  2. Direct mode (fallback): reads from mounted K8s secret volume or env vars — used for local development where no Destination Service is available.

CBC credentials are stored in a BTP Destination entry, so we should support the same pattern: CLOUD_SDK_CBC_DESTINATION_NAME → fetch URL + cert from the destination, with env vars as the local fallback. I'll double check how the CBC URL and credentials are stored in the destination and implement this mirroring the aicore approach — destination mode when CLOUD_SDK_CBC_DESTINATION_NAME is set, env/file fallback otherwise. Does that sound right?

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.
…h params

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.
…nts, version bump

- 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
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Add CBC (Central Business Configuration) consumption support

2 participants