diff --git a/pyproject.toml b/pyproject.toml index 85f60f65..5aa3e57d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.45.4" +version = "0.46.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/core/runtime_context/__init__.py b/src/sap_cloud_sdk/core/runtime_context/__init__.py index 397da80c..16d8ba29 100644 --- a/src/sap_cloud_sdk/core/runtime_context/__init__.py +++ b/src/sap_cloud_sdk/core/runtime_context/__init__.py @@ -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 @@ -50,6 +52,7 @@ "DWC_SUBDOMAIN", "DWC_TENANT", "DWCContextProvider", + "FEATURE_TOGGLES", "FrameworkAdapter", "GLOBAL_TENANT_ID", "IASContextProvider", @@ -59,5 +62,6 @@ "TRIGGER_TYPE", "USER_ID", "get_context", + "is_feature_enabled", "register", ] diff --git a/src/sap_cloud_sdk/core/runtime_context/_context.py b/src/sap_cloud_sdk/core/runtime_context/_context.py index 11102ed7..ebbda154 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_context.py +++ b/src/sap_cloud_sdk/core/runtime_context/_context.py @@ -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") @@ -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.""" diff --git a/src/sap_cloud_sdk/core/runtime_context/_keys.py b/src/sap_cloud_sdk/core/runtime_context/_keys.py index 83e599c7..25e5a2bd 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_keys.py +++ b/src/sap_cloud_sdk/core/runtime_context/_keys.py @@ -1,6 +1,6 @@ """Typed context key for RuntimeContext.""" -from typing import Generic, TypeVar +from typing import Generic, List, TypeVar T = TypeVar("T") @@ -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") diff --git a/src/sap_cloud_sdk/core/runtime_context/providers/_dwc.py b/src/sap_cloud_sdk/core/runtime_context/providers/_dwc.py index d59c719c..96841ffc 100644 --- a/src/sap_cloud_sdk/core/runtime_context/providers/_dwc.py +++ b/src/sap_cloud_sdk/core/runtime_context/providers/_dwc.py @@ -1,10 +1,22 @@ """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. @@ -13,6 +25,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: @@ -21,4 +34,20 @@ 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) + toggles = [f["name"] for f in data.get("features", []) if f.get("enabled")] + logger.debug("Feature toggles from dwc-stage-configuration: %s", toggles) + return toggles + except Exception as e: + logger.debug("Failed to parse dwc-stage-configuration header: %s", e) + return None diff --git a/src/sap_cloud_sdk/core/runtime_context/user-guide.md b/src/sap_cloud_sdk/core/runtime_context/user-guide.md index 2508021f..e9709d13 100644 --- a/src/sap_cloud_sdk/core/runtime_context/user-guide.md +++ b/src/sap_cloud_sdk/core/runtime_context/user-guide.md @@ -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 @@ -95,7 +95,49 @@ metadata or a message queue envelope — as long as the adapter populates |---|---|---| | `IASContextProvider` | `Authorization: Bearer ` | `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. + +> **Common pitfall:** `is_feature_enabled` always returns `False` if `bootstrap(app)` was never +> called. Without it, no middleware is registered, the `dwc-stage-configuration` header is never +> parsed, and `get_context()` returns an empty context for every request. Make sure `bootstrap` is +> called once at app startup before the server starts accepting requests. + +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 diff --git a/tests/core/unit/runtime_context/test_runtime_context.py b/tests/core/unit/runtime_context/test_runtime_context.py index 1a38e119..ae417b1c 100644 --- a/tests/core/unit/runtime_context/test_runtime_context.py +++ b/tests/core/unit/runtime_context/test_runtime_context.py @@ -1,5 +1,7 @@ """Tests for sap_cloud_sdk.core.runtime_context.""" +import base64 +import json import pytest from unittest.mock import MagicMock, patch @@ -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, @@ -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"}) @@ -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