Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sap-cloud-sdk"
version = "0.45.3"
version = "0.46.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
4 changes: 4 additions & 0 deletions src/sap_cloud_sdk/core/runtime_context/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@
from sap_cloud_sdk.core.runtime_context._context import (
RuntimeContext,
get_context,
is_feature_enabled,
)
from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope
from sap_cloud_sdk.core.runtime_context._keys import (
ContextKey,
DWC_SUBDOMAIN,
DWC_TENANT,
FEATURE_TOGGLES,
TRIGGER_TYPE,
)
from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider
Expand All @@ -50,6 +52,7 @@
"DWC_SUBDOMAIN",
"DWC_TENANT",
"DWCContextProvider",
"FEATURE_TOGGLES",
"FrameworkAdapter",
"GLOBAL_TENANT_ID",
"IASContextProvider",
Expand All @@ -59,5 +62,6 @@
"TRIGGER_TYPE",
"USER_ID",
"get_context",
"is_feature_enabled",
"register",
]
13 changes: 12 additions & 1 deletion src/sap_cloud_sdk/core/runtime_context/_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from contextvars import ContextVar
from typing import Any, AsyncGenerator, Dict, Generator, Optional, TypeVar

from sap_cloud_sdk.core.runtime_context._keys import ContextKey
from sap_cloud_sdk.core.runtime_context._keys import ContextKey, FEATURE_TOGGLES

T = TypeVar("T")

Expand Down Expand Up @@ -64,6 +64,17 @@ def get_context() -> RuntimeContext:
return _context_var.get()


def is_feature_enabled(name: str) -> bool:
"""Return ``True`` if *name* is in the active feature toggles for the current request.

Feature toggles are populated from the ``dwc-stage-configuration`` DWC request
header by :class:`~sap_cloud_sdk.core.runtime_context.DWCContextProvider`.
Returns ``False`` when no toggles header was present or the toggle is absent.
"""
toggles = get_context().get(FEATURE_TOGGLES)
return name in toggles if toggles is not None else False


@contextmanager
def sdk_context(ctx: RuntimeContext) -> Generator[RuntimeContext, None, None]:
"""Sync context manager that sets *ctx* for the duration of the block."""
Expand Down
3 changes: 2 additions & 1 deletion src/sap_cloud_sdk/core/runtime_context/_keys.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Typed context key for RuntimeContext."""

from typing import Generic, TypeVar
from typing import Generic, List, TypeVar

T = TypeVar("T")

Expand Down Expand Up @@ -37,3 +37,4 @@ def __repr__(self) -> str:
TRIGGER_TYPE = ContextKey[str]("trigger_type")
DWC_SUBDOMAIN = ContextKey[str]("dwc_subdomain")
DWC_TENANT = ContextKey[str]("dwc_tenant")
FEATURE_TOGGLES = ContextKey[List[str]]("dwc_feature_toggles")
25 changes: 24 additions & 1 deletion src/sap_cloud_sdk/core/runtime_context/providers/_dwc.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
"""DWC context provider."""

import base64
import json
import logging

from sap_cloud_sdk.core.runtime_context._context import RuntimeContext
from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope
from sap_cloud_sdk.core.runtime_context._keys import DWC_SUBDOMAIN, DWC_TENANT
from sap_cloud_sdk.core.runtime_context._keys import DWC_SUBDOMAIN, DWC_TENANT, FEATURE_TOGGLES
from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider

logger = logging.getLogger(__name__)

_FEATURE_TOGGLES_HEADER = "dwc-stage-configuration"


class DWCContextProvider(ContextProvider):
"""Extracts DWC tenant context from SAP DWC request headers.
Expand All @@ -13,6 +21,7 @@ class DWCContextProvider(ContextProvider):

- :data:`~sap_cloud_sdk.core.runtime_context.DWC_SUBDOMAIN` from ``dwc-subdomain``
- :data:`~sap_cloud_sdk.core.runtime_context.DWC_TENANT` from ``dwc-tenant``
- :data:`~sap_cloud_sdk.core.runtime_context.FEATURE_TOGGLES` from ``dwc-stage-configuration``
"""

def extract(self, envelope: RequestEnvelope) -> RuntimeContext:
Expand All @@ -21,4 +30,18 @@ def extract(self, envelope: RequestEnvelope) -> RuntimeContext:
values[DWC_SUBDOMAIN] = subdomain
if tenant := envelope.headers.get("dwc-tenant"):
values[DWC_TENANT] = tenant
if raw := envelope.headers.get(_FEATURE_TOGGLES_HEADER):
toggles = _parse_feature_toggles(raw)
if toggles is not None:
values[FEATURE_TOGGLES] = toggles
return RuntimeContext(values)


def _parse_feature_toggles(raw: str) -> list[str] | None:
try:
decoded = base64.b64decode(raw).decode()
data = json.loads(decoded)
return [f["name"] for f in data.get("features", []) if f.get("enabled")]
except Exception as e:
logger.debug("Failed to parse dwc-stage-configuration header: %s", e)
return None
39 changes: 37 additions & 2 deletions src/sap_cloud_sdk/core/runtime_context/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ bootstrap(app)

By default `bootstrap` registers `IASContextProvider` (reads IAS JWT),
`SAPTriggerContextProvider` (reads `x-sap-origin`), and `DWCContextProvider`
(reads `dwc-subdomain` and `dwc-tenant`).
(reads `dwc-subdomain`, `dwc-tenant`, and `dwc-stage-configuration`).

### 2. Read context anywhere

Expand Down Expand Up @@ -95,7 +95,42 @@ metadata or a message queue envelope — as long as the adapter populates
|---|---|---|
| `IASContextProvider` | `Authorization: Bearer <JWT>` | `TENANT_ID`, `USER_ID`, `GLOBAL_TENANT_ID` |
| `SAPTriggerContextProvider` | `x-sap-origin` | `TRIGGER_TYPE` |
| `DWCContextProvider` | `dwc-subdomain`, `dwc-tenant` | `DWC_SUBDOMAIN`, `DWC_TENANT` |
| `DWCContextProvider` | `dwc-subdomain`, `dwc-tenant`, `dwc-stage-configuration` | `DWC_SUBDOMAIN`, `DWC_TENANT`, `FEATURE_TOGGLES` |

### Feature toggles

`DWCContextProvider` reads the `dwc-stage-configuration` header. The value is
base64-encoded JSON with the shape:

```json
{
"features": [
{"name": "MY_FEATURE", "enabled": true},
{"name": "OTHER_FEATURE", "enabled": false}
]
}
```

Only features with `"enabled": true` are included. Use `is_feature_enabled(name)`
to check a toggle for the current request:

```python
from sap_cloud_sdk.core.runtime_context import is_feature_enabled

@app.route("/")
async def handler(request):
if is_feature_enabled("my-feature"):
...
```

`is_feature_enabled` returns `False` when the header is absent or the toggle
name is not in the active list. For direct access to the full list:

```python
from sap_cloud_sdk.core.runtime_context import FEATURE_TOGGLES, get_context

toggles = get_context().get(FEATURE_TOGGLES) # List[str] | None
```

### Custom providers

Expand Down
67 changes: 67 additions & 0 deletions tests/core/unit/runtime_context/test_runtime_context.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for sap_cloud_sdk.core.runtime_context."""

import base64
import json
import pytest
from unittest.mock import MagicMock, patch

Expand All @@ -9,12 +11,14 @@
DWC_SUBDOMAIN,
DWC_TENANT,
DWCContextProvider,
FEATURE_TOGGLES,
IASContextProvider,
RuntimeContext,
RequestEnvelope,
SAPTriggerContextProvider,
TRIGGER_TYPE,
get_context,
is_feature_enabled,
)
from sap_cloud_sdk.core.runtime_context._context import (
async_sdk_context,
Expand Down Expand Up @@ -312,6 +316,10 @@ def test_satisfies_context_provider_protocol(self):
# ---------------------------------------------------------------------------


def _make_stage_config(features: list[dict]) -> str:
return base64.b64encode(json.dumps({"features": features}).encode()).decode()


class TestDWCContextProvider:
def test_extracts_dwc_subdomain(self):
envelope = _make_envelope({"dwc-subdomain": "my-subdomain"})
Expand All @@ -332,6 +340,65 @@ def test_returns_empty_when_no_headers(self):
def test_satisfies_context_provider_protocol(self):
assert isinstance(DWCContextProvider(), ContextProvider)

def test_extracts_enabled_feature_toggles(self):
header = _make_stage_config([
{"name": "FEATURE_A", "enabled": True},
{"name": "FEATURE_B", "enabled": True},
])
ctx = DWCContextProvider().extract(_make_envelope({"dwc-stage-configuration": header}))
assert ctx.get(FEATURE_TOGGLES) == ["FEATURE_A", "FEATURE_B"]

def test_excludes_disabled_feature_toggles(self):
header = _make_stage_config([
{"name": "FEATURE_A", "enabled": True},
{"name": "FEATURE_B", "enabled": False},
])
ctx = DWCContextProvider().extract(_make_envelope({"dwc-stage-configuration": header}))
assert ctx.get(FEATURE_TOGGLES) == ["FEATURE_A"]

def test_feature_toggles_none_when_header_absent(self):
ctx = DWCContextProvider().extract(_make_envelope({}))
assert ctx.get(FEATURE_TOGGLES) is None

def test_feature_toggles_none_on_invalid_base64(self):
ctx = DWCContextProvider().extract(_make_envelope({"dwc-stage-configuration": "!!!"}))
assert ctx.get(FEATURE_TOGGLES) is None

def test_feature_toggles_none_on_invalid_json(self):
bad = base64.b64encode(b"not-json").decode()
ctx = DWCContextProvider().extract(_make_envelope({"dwc-stage-configuration": bad}))
assert ctx.get(FEATURE_TOGGLES) is None


# ---------------------------------------------------------------------------
# is_feature_enabled
# ---------------------------------------------------------------------------


class TestIsFeatureEnabled:
def test_returns_true_when_toggle_is_active(self):
ctx = RuntimeContext({FEATURE_TOGGLES: ["my-feature", "other"]})
with sdk_context(ctx):
assert is_feature_enabled("my-feature") is True

def test_returns_false_when_toggle_is_not_in_list(self):
ctx = RuntimeContext({FEATURE_TOGGLES: ["other-feature"]})
with sdk_context(ctx):
assert is_feature_enabled("my-feature") is False

def test_returns_false_when_toggle_list_is_empty(self):
ctx = RuntimeContext({FEATURE_TOGGLES: []})
with sdk_context(ctx):
assert is_feature_enabled("my-feature") is False

def test_returns_false_when_no_toggles_header(self):
ctx = RuntimeContext()
with sdk_context(ctx):
assert is_feature_enabled("my-feature") is False

def teardown_method(self):
set_context(RuntimeContext())


# ---------------------------------------------------------------------------
# _merge
Expand Down
Loading