diff --git a/pyproject.toml b/pyproject.toml index 76f92cb0..1910a4a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.38.0" +version = "0.39.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/__init__.py b/src/sap_cloud_sdk/__init__.py index 25b731d5..4bb4e72d 100644 --- a/src/sap_cloud_sdk/__init__.py +++ b/src/sap_cloud_sdk/__init__.py @@ -1 +1,5 @@ # SAP Cloud SDK for Python + +from sap_cloud_sdk.core.bootstrap import bootstrap + +__all__ = ["bootstrap"] diff --git a/src/sap_cloud_sdk/core/auditlog_ng/client.py b/src/sap_cloud_sdk/core/auditlog_ng/client.py index 12748255..01329400 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/client.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/client.py @@ -38,6 +38,8 @@ from sap_cloud_sdk.core.auditlog_ng.exceptions import ValidationError from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics from sap_cloud_sdk.core.telemetry.config import ENV_OTLP_PROTOCOL +from sap_cloud_sdk.core.runtime_context import get_context +from sap_cloud_sdk.core.runtime_context.providers import TENANT_ID, USER_ID from sap_cloud_sdk.ias._context import get_auth_context @@ -48,16 +50,24 @@ def _fill_common_from_auth_context(event: Message) -> None: mutation, so callers that never touched common still get it populated. Sets tenant_id and user_initiator_id from IAS claims (if present and not already set), and sets timestamp to now if the caller left it at zero. + + Reads from the SDK runtime context first (populated by bootstrap()), then + falls back to the legacy IAS auth context for apps not yet using bootstrap(). """ if not hasattr(event, "common"): return common = cast(Any, event.common) + + ctx = get_context() claims = get_auth_context() - if claims is not None: - if claims.app_tid and not common.tenant_id: - common.tenant_id = claims.app_tid - if claims.user_uuid and not common.user_initiator_id: - common.user_initiator_id = claims.user_uuid + + tenant_id = ctx.get(TENANT_ID) or (claims and claims.app_tid) + user_id = ctx.get(USER_ID) or (claims and claims.user_uuid) + + if tenant_id and not common.tenant_id: + common.tenant_id = tenant_id + if user_id and not common.user_initiator_id: + common.user_initiator_id = user_id if common.timestamp.seconds == 0: common.timestamp.FromDatetime(datetime.now(timezone.utc)) diff --git a/src/sap_cloud_sdk/core/bootstrap.py b/src/sap_cloud_sdk/core/bootstrap.py new file mode 100644 index 00000000..5c716afa --- /dev/null +++ b/src/sap_cloud_sdk/core/bootstrap.py @@ -0,0 +1,62 @@ +"""Top-level bootstrap() entry point for the SAP Cloud SDK.""" + +from typing import Any, List, Optional + +from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider +from sap_cloud_sdk.core.runtime_context._registry import get_registry +from sap_cloud_sdk.core.runtime_context import ( + DWCContextProvider, + IASContextProvider, + SAPTriggerContextProvider, +) +from sap_cloud_sdk.core.telemetry import Module, Operation +from sap_cloud_sdk.core.telemetry.metrics_decorator import record_metrics + + +@record_metrics(Module.BOOTSTRAP, Operation.BOOTSTRAP) +def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> None: + """Wire the SDK runtime context into your application framework. + + Call once at startup. On every inbound request the SDK will run all + *providers* against the request, merge the results, and make them + available via :func:`~sap_cloud_sdk.core.runtime_context.get_context`. + + The framework is detected automatically from the *app* type via the + registered :class:`~sap_cloud_sdk.core.runtime_context.FrameworkAdapter` + instances — adding support for a new framework never requires editing + this function. + + Args: + app: The application instance to attach the middleware to. + providers: Context providers to run on each request. Defaults to + ``[IASContextProvider(), SAPTriggerContextProvider(), DWCContextProvider()]``. + + Raises: + TypeError: If no registered adapter recognises *app*. + + Example:: + + from sap_cloud_sdk import bootstrap + + bootstrap(app) # IAS + SAP trigger + DWC by default + + # custom providers: + bootstrap(app, providers=[IASContextProvider(), MyProvider()]) + """ + if not providers: + providers = [ + IASContextProvider(), + SAPTriggerContextProvider(), + DWCContextProvider(), + ] + + for adapter in get_registry(): + if adapter.matches(app): + adapter.attach(app, providers) + return + + raise TypeError( + f"bootstrap() does not recognise app type {type(app)!r}. " + "Supported frameworks are determined by registered FrameworkAdapters. " + "For other frameworks, register a FrameworkAdapter or attach the middleware manually." + ) diff --git a/src/sap_cloud_sdk/core/runtime_context/__init__.py b/src/sap_cloud_sdk/core/runtime_context/__init__.py new file mode 100644 index 00000000..1403a921 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/__init__.py @@ -0,0 +1,63 @@ +"""SDK-wide runtime context for the current execution. + +Lets SDK modules read caller-identity information (tenant, user, trigger type) +without knowing about the invocation source — HTTP, gRPC, message queue, etc. + +Wire once at startup:: + + from sap_cloud_sdk import bootstrap + + bootstrap(app) # defaults to IASContextProvider + SAPTriggerContextProvider + DWCContextProvider + +Then read anywhere:: + + from sap_cloud_sdk.core.runtime_context import get_context, TENANT_ID, USER_ID + + ctx = get_context() + ctx.get(TENANT_ID) # -> "abc-123" or None + ctx.get(USER_ID) # -> "user-uuid" or None +""" + +from sap_cloud_sdk.core.runtime_context._context import ( + RuntimeContext, + get_context, +) +from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope +from sap_cloud_sdk.core.runtime_context._keys import ( + ContextKey, + DWC_SUBDOMAIN, + DWC_TENANT, + TRIGGER_TYPE, +) +from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider +from sap_cloud_sdk.core.runtime_context._registry import FrameworkAdapter, register +from sap_cloud_sdk.core.runtime_context.providers import ( + DWCContextProvider, + IASContextProvider, + SAPTriggerContextProvider, + GLOBAL_TENANT_ID, + TENANT_ID, + USER_ID, +) + +# Register built-in framework adapters (guarded so missing extras don't break the import). +import sap_cloud_sdk.core.runtime_context.adapters # noqa: F401 + +__all__ = [ + "ContextKey", + "ContextProvider", + "DWC_SUBDOMAIN", + "DWC_TENANT", + "DWCContextProvider", + "FrameworkAdapter", + "GLOBAL_TENANT_ID", + "IASContextProvider", + "RuntimeContext", + "RequestEnvelope", + "SAPTriggerContextProvider", + "TENANT_ID", + "TRIGGER_TYPE", + "USER_ID", + "get_context", + "register", +] diff --git a/src/sap_cloud_sdk/core/runtime_context/_context.py b/src/sap_cloud_sdk/core/runtime_context/_context.py new file mode 100644 index 00000000..11102ed7 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/_context.py @@ -0,0 +1,86 @@ +"""ContextVar backing store for the SDK runtime context.""" + +from contextlib import asynccontextmanager, contextmanager +from contextvars import ContextVar +from typing import Any, AsyncGenerator, Dict, Generator, Optional, TypeVar + +from sap_cloud_sdk.core.runtime_context._keys import ContextKey + +T = TypeVar("T") + + +class RuntimeContext: + """Immutable typed bag of caller-identity values for the current execution. + + Values are keyed by :class:`ContextKey` instances, which carry the + expected type. Use :meth:`get` to read a value and :meth:`with_value` + to produce a new context with an additional entry. + + Example:: + + MY_KEY = ContextKey[str]("my_key") + + ctx = RuntimeContext({MY_KEY: "hello"}) + ctx.get(MY_KEY) # -> "hello" + """ + + def __init__(self, values: Optional[Dict[ContextKey, Any]] = None) -> None: + self._values: Dict[ContextKey, Any] = dict(values) if values else {} + + def get(self, key: ContextKey[T]) -> Optional[T]: + """Return the value for *key*, or ``None`` if not set.""" + return self._values.get(key) + + def with_value(self, key: ContextKey[T], value: T) -> "RuntimeContext": + """Return a new RuntimeContext with *key* set to *value*.""" + return RuntimeContext({**self._values, key: value}) + + def _raw(self) -> Dict[ContextKey, Any]: + """Return a shallow copy of the internal values dict.""" + return dict(self._values) + + def __repr__(self) -> str: + pairs = ", ".join(f"{k.name}={v!r}" for k, v in self._values.items()) + return f"RuntimeContext({{{pairs}}})" + + +_EMPTY = RuntimeContext() + +_context_var: ContextVar[RuntimeContext] = ContextVar( + "sap_sdk_request_context", default=_EMPTY +) + + +def set_context(ctx: RuntimeContext) -> None: + """Set the runtime context for the current async/thread scope.""" + _context_var.set(ctx) + + +def get_context() -> RuntimeContext: + """Return the runtime context for the current async/thread scope. + + Returns an empty :class:`RuntimeContext` when no context has been set. + """ + return _context_var.get() + + +@contextmanager +def sdk_context(ctx: RuntimeContext) -> Generator[RuntimeContext, None, None]: + """Sync context manager that sets *ctx* for the duration of the block.""" + token = _context_var.set(ctx) + try: + yield ctx + finally: + _context_var.reset(token) + + +@asynccontextmanager +async def async_sdk_context( + ctx: RuntimeContext, +) -> AsyncGenerator[RuntimeContext, None]: + """Async context manager that sets *ctx* for the duration of the block.""" + token = _context_var.set(ctx) + try: + yield ctx + finally: + _context_var.reset(token) diff --git a/src/sap_cloud_sdk/core/runtime_context/_envelope.py b/src/sap_cloud_sdk/core/runtime_context/_envelope.py new file mode 100644 index 00000000..5e7e2314 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/_envelope.py @@ -0,0 +1,24 @@ +"""Framework-agnostic request envelope passed to ContextProviders.""" + +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + + +@dataclass +class RequestEnvelope: + """Framework-agnostic view of an inbound request passed to providers. + + The framework middleware populates this; providers read from it. This + means providers work identically regardless of whether the request came + from Starlette, Flask, gRPC, or a test. + + Attributes: + headers: Request headers as a plain dict (lowercased keys recommended). + body: Raw request body. ``None`` if not needed by any provider. + metadata: Framework-specific extras — query params, gRPC metadata, etc. + Currently unused; reserved for future providers. + """ + + headers: Dict[str, str] = field(default_factory=dict) + body: Optional[bytes] = field(default=None) + metadata: Dict[str, Any] = field(default_factory=dict) diff --git a/src/sap_cloud_sdk/core/runtime_context/_keys.py b/src/sap_cloud_sdk/core/runtime_context/_keys.py new file mode 100644 index 00000000..83e599c7 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/_keys.py @@ -0,0 +1,39 @@ +"""Typed context key for RuntimeContext.""" + +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class ContextKey(Generic[T]): + """A typed key for reading and writing values in a :class:`RuntimeContext`. + + Each provider defines its own keys. The type parameter ensures consumers + get the right type back from :meth:`RuntimeContext.get`. + + Keys use object identity for lookup — two ``ContextKey`` instances with the + same name are **different keys**. Always import the canonical key from the + module that defined it; never create a second instance with the same name. + + Example:: + + MY_KEY = ContextKey[str]("my_key") + + ctx = RuntimeContext({MY_KEY: "value"}) + ctx.get(MY_KEY) # -> "value" + + other = ContextKey[str]("my_key") + ctx.get(other) # -> None (different key object) + """ + + def __init__(self, name: str) -> None: + self.name = name + + def __repr__(self) -> str: + return f"ContextKey({self.name!r})" + + +# SDK-standard keys — not tied to any specific auth provider. +TRIGGER_TYPE = ContextKey[str]("trigger_type") +DWC_SUBDOMAIN = ContextKey[str]("dwc_subdomain") +DWC_TENANT = ContextKey[str]("dwc_tenant") diff --git a/src/sap_cloud_sdk/core/runtime_context/_protocol.py b/src/sap_cloud_sdk/core/runtime_context/_protocol.py new file mode 100644 index 00000000..c0f844e4 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/_protocol.py @@ -0,0 +1,27 @@ +"""ContextProvider protocol — the pluggable extraction interface.""" + +from typing import Protocol, runtime_checkable + +from sap_cloud_sdk.core.runtime_context._context import RuntimeContext +from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope + + +@runtime_checkable +class ContextProvider(Protocol): + """Extracts a :class:`RuntimeContext` from a :class:`RequestEnvelope`. + + Implement this to add a new auth provider or header convention. The envelope + is framework-agnostic — providers never touch Starlette, Flask, or gRPC types. + + Example:: + + MY_KEY = ContextKey[str]("my_key") + + class MyProvider(ContextProvider): + def extract(self, envelope: RequestEnvelope) -> RuntimeContext: + value = envelope.headers.get("x-my-header", "") + return RuntimeContext({MY_KEY: value} if value else {}) + """ + + def extract(self, envelope: RequestEnvelope) -> RuntimeContext: # pragma: no cover + ... diff --git a/src/sap_cloud_sdk/core/runtime_context/_registry.py b/src/sap_cloud_sdk/core/runtime_context/_registry.py new file mode 100644 index 00000000..09e168fd --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/_registry.py @@ -0,0 +1,57 @@ +"""Framework adapter base class and registry for bootstrap().""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from typing import List + +from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider + +logger = logging.getLogger(__name__) + +_registry: List[FrameworkAdapter] = [] + + +def register(adapter: FrameworkAdapter) -> None: + """Register a framework adapter with the bootstrap registry.""" + _registry.append(adapter) + + +def get_registry() -> List[FrameworkAdapter]: + return list(_registry) + + +class FrameworkAdapter(ABC): + """Connects a framework or invocation source to the SDK runtime context. + + Subclasses know how to detect a specific app type and attach the SDK's + context pipeline to it. Register at module level so that + :func:`~sap_cloud_sdk.core.bootstrap.bootstrap` can discover them without + importing framework-specific code directly. + + Example:: + + class FlaskContextAdapter(FrameworkAdapter): + def _matches(self, app) -> bool: + from flask import Flask + return isinstance(app, Flask) + + def attach(self, app, providers) -> None: + app.before_request(lambda: ...) + + register(FlaskContextAdapter()) + """ + + def matches(self, app) -> bool: + """Return True if this adapter handles *app*'s framework type.""" + try: + return self._matches(app) + except ImportError: + return False + + @abstractmethod + def _matches(self, app) -> bool: ... + + @abstractmethod + def attach(self, app, providers: List[ContextProvider]) -> None: ... diff --git a/src/sap_cloud_sdk/core/runtime_context/adapters/__init__.py b/src/sap_cloud_sdk/core/runtime_context/adapters/__init__.py new file mode 100644 index 00000000..5f1f63a2 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/adapters/__init__.py @@ -0,0 +1,6 @@ +"""Built-in framework adapters.""" + +try: + from sap_cloud_sdk.core.runtime_context.adapters import _starlette # noqa: F401 +except ImportError: + pass diff --git a/src/sap_cloud_sdk/core/runtime_context/adapters/_starlette.py b/src/sap_cloud_sdk/core/runtime_context/adapters/_starlette.py new file mode 100644 index 00000000..14df6f92 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/adapters/_starlette.py @@ -0,0 +1,23 @@ +"""Starlette/FastAPI framework adapter.""" + +from typing import List + +from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider +from sap_cloud_sdk.core.runtime_context._registry import FrameworkAdapter, register + + +class _StarletteContextAdapter(FrameworkAdapter): + def _matches(self, app) -> bool: + from starlette.applications import Starlette + + return isinstance(app, Starlette) + + def attach(self, app, providers: List[ContextProvider]) -> None: + from sap_cloud_sdk.core.runtime_context.starlette import ( + StarletteContextMiddleware, + ) + + app.add_middleware(StarletteContextMiddleware, providers=providers) + + +register(_StarletteContextAdapter()) diff --git a/src/sap_cloud_sdk/core/runtime_context/providers/__init__.py b/src/sap_cloud_sdk/core/runtime_context/providers/__init__.py new file mode 100644 index 00000000..4b592f36 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/providers/__init__.py @@ -0,0 +1,21 @@ +"""Built-in context providers.""" + +from sap_cloud_sdk.core.runtime_context.providers._dwc import DWCContextProvider +from sap_cloud_sdk.core.runtime_context.providers._ias import ( + GLOBAL_TENANT_ID, + IASContextProvider, + TENANT_ID, + USER_ID, +) +from sap_cloud_sdk.core.runtime_context.providers._sap_trigger import ( + SAPTriggerContextProvider, +) + +__all__ = [ + "DWCContextProvider", + "GLOBAL_TENANT_ID", + "IASContextProvider", + "SAPTriggerContextProvider", + "TENANT_ID", + "USER_ID", +] diff --git a/src/sap_cloud_sdk/core/runtime_context/providers/_dwc.py b/src/sap_cloud_sdk/core/runtime_context/providers/_dwc.py new file mode 100644 index 00000000..d59c719c --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/providers/_dwc.py @@ -0,0 +1,24 @@ +"""DWC context provider.""" + +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._protocol import ContextProvider + + +class DWCContextProvider(ContextProvider): + """Extracts DWC tenant context from SAP DWC request headers. + + Defines and populates the following context keys: + + - :data:`~sap_cloud_sdk.core.runtime_context.DWC_SUBDOMAIN` from ``dwc-subdomain`` + - :data:`~sap_cloud_sdk.core.runtime_context.DWC_TENANT` from ``dwc-tenant`` + """ + + def extract(self, envelope: RequestEnvelope) -> RuntimeContext: + values = {} + if subdomain := envelope.headers.get("dwc-subdomain"): + values[DWC_SUBDOMAIN] = subdomain + if tenant := envelope.headers.get("dwc-tenant"): + values[DWC_TENANT] = tenant + return RuntimeContext(values) diff --git a/src/sap_cloud_sdk/core/runtime_context/providers/_ias.py b/src/sap_cloud_sdk/core/runtime_context/providers/_ias.py new file mode 100644 index 00000000..c5fafebe --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/providers/_ias.py @@ -0,0 +1,50 @@ +"""IAS context provider and its typed keys.""" + +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 ContextKey +from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider +from sap_cloud_sdk.ias import parse_token + +logger = logging.getLogger(__name__) + +TENANT_ID = ContextKey[str]("ias.app_tid") +GLOBAL_TENANT_ID = ContextKey[str]("ias.sap_gtid") +USER_ID = ContextKey[str]("ias.user_uuid") + + +class IASContextProvider(ContextProvider): + """Extracts tenant/user context from an IAS JWT ``Authorization`` header. + + Reads from a :class:`~sap_cloud_sdk.core.runtime_context.RequestEnvelope` + — works with any framework. + + Defines and populates the following context keys: + + - :data:`TENANT_ID` from ``app_tid`` claim + - :data:`GLOBAL_TENANT_ID` from ``sap_gtid`` claim + - :data:`USER_ID` from ``user_uuid`` claim + """ + + def extract(self, envelope: RequestEnvelope) -> RuntimeContext: + auth = envelope.headers.get("authorization", "") + + claims = None + if auth: + try: + claims = parse_token(auth) + except Exception as e: + logger.debug("IAS token parsing failed, context will be empty: %s", e) + + values = {} + if claims: + if claims.app_tid: + values[TENANT_ID] = claims.app_tid + if claims.sap_gtid: + values[GLOBAL_TENANT_ID] = claims.sap_gtid + if claims.user_uuid: + values[USER_ID] = claims.user_uuid + + return RuntimeContext(values) diff --git a/src/sap_cloud_sdk/core/runtime_context/providers/_sap_trigger.py b/src/sap_cloud_sdk/core/runtime_context/providers/_sap_trigger.py new file mode 100644 index 00000000..6414f95f --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/providers/_sap_trigger.py @@ -0,0 +1,19 @@ +"""SAP trigger type context provider.""" + +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 TRIGGER_TYPE +from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider + + +class SAPTriggerContextProvider(ContextProvider): + """Extracts the SAP trigger type from the ``x-sap-origin`` header. + + Defines and populates the following context key: + + - :data:`~sap_cloud_sdk.core.runtime_context.TRIGGER_TYPE` from ``x-sap-origin`` + """ + + def extract(self, envelope: RequestEnvelope) -> RuntimeContext: + origin = envelope.headers.get("x-sap-origin") + return RuntimeContext({TRIGGER_TYPE: origin} if origin else {}) diff --git a/src/sap_cloud_sdk/core/runtime_context/starlette.py b/src/sap_cloud_sdk/core/runtime_context/starlette.py new file mode 100644 index 00000000..eb0fcb05 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/starlette.py @@ -0,0 +1,52 @@ +"""Starlette/FastAPI context middleware.""" + +from typing import Any, List + +from sap_cloud_sdk.core.runtime_context._context import ( + RuntimeContext, + async_sdk_context, +) +from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope +from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider + +try: + from starlette.middleware.base import BaseHTTPMiddleware + from starlette.requests import Request + from starlette.responses import Response +except ImportError as exc: + raise ImportError( + "The 'starlette' package is required to use StarletteContextMiddleware. " + "Install it with: pip install starlette" + ) from exc + + +def _merge(contexts: List[RuntimeContext]) -> RuntimeContext: + """Merge multiple RuntimeContexts — first writer wins per key.""" + merged: dict = {} + for ctx in contexts: + for key, value in ctx._raw().items(): + merged.setdefault(key, value) + return RuntimeContext(merged) + + +class StarletteContextMiddleware(BaseHTTPMiddleware): + """Starlette/FastAPI middleware that populates the SDK runtime context. + + Builds a :class:`~sap_cloud_sdk.core.runtime_context.RequestEnvelope` from + each inbound request, runs all *providers* against it, and merges the results + into a single :class:`~sap_cloud_sdk.core.runtime_context.RuntimeContext` + available via :func:`~sap_cloud_sdk.core.runtime_context.get_context` for + the duration of that request. + + First writer wins per key when merging across providers. + """ + + def __init__(self, app: Any, providers: List[ContextProvider]) -> None: + super().__init__(app) + self._providers = providers + + async def dispatch(self, request: Request, call_next: Any) -> Response: + envelope = RequestEnvelope(headers=dict(request.headers)) + ctx = _merge([p.extract(envelope) for p in self._providers]) + async with async_sdk_context(ctx): + return await call_next(request) diff --git a/src/sap_cloud_sdk/core/runtime_context/user-guide.md b/src/sap_cloud_sdk/core/runtime_context/user-guide.md new file mode 100644 index 00000000..0bb989e8 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/user-guide.md @@ -0,0 +1,166 @@ +# Runtime Context User Guide + +## How it works + +The runtime context lets SDK modules read caller-identity information (tenant, +user, trigger type) for the current execution — without knowing where that +information came from or what framework is running. + +- **`bootstrap(app)`** wires the SDK into your framework once at startup. +- **Providers** extract context from the current invocation (HTTP request, gRPC call, Kubernetes event, etc.). +- **`get_context()`** lets any module read that context via typed keys. + +``` +bootstrap(app) + └─ registers middleware on your framework + └─ on each invocation: providers extract → RuntimeContext set in ContextVar + └─ anywhere: get_context().get(TENANT_ID) +``` + +--- + +## Quick start + +### 1. Bootstrap at app startup + +```python +from starlette.applications import Starlette +from sap_cloud_sdk import bootstrap + +app = Starlette(...) +bootstrap(app) +``` + +By default `bootstrap` registers `IASContextProvider` (reads IAS JWT), +`SAPTriggerContextProvider` (reads `x-sap-origin`), and `DWCContextProvider` +(reads `dwc-subdomain` and `dwc-tenant`). + +### 2. Read context anywhere + +```python +from sap_cloud_sdk.core.runtime_context import get_context, TENANT_ID, USER_ID, TRIGGER_TYPE + +ctx = get_context() +ctx.get(TENANT_ID) # -> "abc-123" or None +ctx.get(USER_ID) # -> "user-uuid" or None +ctx.get(TRIGGER_TYPE) # -> "ui5" or None +``` + +--- + +## Context keys + +Values are stored and retrieved by typed `ContextKey` instances — not strings. +Each provider owns the keys it defines. Import keys from the provider that +defined them. + +```python +# IAS-owned keys: +from sap_cloud_sdk.core.runtime_context import TENANT_ID, USER_ID, GLOBAL_TENANT_ID + +# SDK-standard keys (not tied to any specific source): +from sap_cloud_sdk.core.runtime_context import TRIGGER_TYPE + +# Define your own: +from sap_cloud_sdk.core.runtime_context import ContextKey + +MY_KEY = ContextKey[str]("my_key") +``` + +Keys are identity-based — two `ContextKey("same_name")` instances are different +keys. Always import the key from the module that defined it. + +--- + +## Providers + +A provider extracts a `RuntimeContext` from a `RequestEnvelope` — a +framework-agnostic carrier of whatever signals were available at invocation time +(headers, body, metadata). The provider doesn't know which framework built the +envelope; the framework adapter doesn't know what the provider does with it. + +This means providers are reusable across transports. An `IASContextProvider` +written for HTTP headers works identically if the same headers appear in gRPC +metadata or a message queue envelope — as long as the adapter populates +`RequestEnvelope.headers` consistently. + +### Built-in providers + +| Provider | Reads | Sets | +|---|---|---| +| `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` | + +### Custom providers + +```python +from sap_cloud_sdk.core.runtime_context import ( + ContextKey, ContextProvider, RuntimeContext, RequestEnvelope +) + +CORRELATION_ID = ContextKey[str]("correlation_id") + +class CorrelationIdProvider(ContextProvider): + def extract(self, envelope: RequestEnvelope) -> RuntimeContext: + value = envelope.headers.get("x-correlation-id") + return RuntimeContext({CORRELATION_ID: value} if value else {}) +``` + +Pass it to `bootstrap`: + +```python +from sap_cloud_sdk.core.runtime_context import ( + IASContextProvider, + SAPTriggerContextProvider, + DWCContextProvider, +) + +bootstrap(app, providers=[IASContextProvider(), SAPTriggerContextProvider(), DWCContextProvider(), CorrelationIdProvider()]) +``` + +### Merging + +When multiple providers are registered, their results are merged — first writer +wins per key. Providers that set different keys don't interfere with each other. + +--- + +## Framework adapters + +`bootstrap` auto-detects the framework from the `app` type via registered +`FrameworkAdapter` instances. Each adapter knows how to intercept invocations +for one framework and build a `RequestEnvelope` from whatever the framework +exposes. Adding support for a new framework or invocation source never requires +editing `bootstrap`. + +### Currently supported + +| Framework | Detected via | +|---|---| +| Starlette / FastAPI | `isinstance(app, Starlette)` | + +### Adding a new framework or invocation source + +```python +from sap_cloud_sdk.core.runtime_context import ContextProvider, FrameworkAdapter, register + +class FlaskContextAdapter(FrameworkAdapter): + def _matches(self, app) -> bool: + from flask import Flask + return isinstance(app, Flask) + + def attach(self, app, providers: list[ContextProvider]) -> None: + from my_flask_middleware import FlaskContextMiddleware + app.before_request(FlaskContextMiddleware(providers).handle) + +register(FlaskContextAdapter()) +``` + +--- + +## Running the tests + +```bash +uv run pytest tests/core/unit/runtime_context/ +``` diff --git a/src/sap_cloud_sdk/core/telemetry/module.py b/src/sap_cloud_sdk/core/telemetry/module.py index d1f605f6..d5aaf69e 100644 --- a/src/sap_cloud_sdk/core/telemetry/module.py +++ b/src/sap_cloud_sdk/core/telemetry/module.py @@ -12,6 +12,7 @@ class Module(str, Enum): AICORE = "aicore" AUDITLOG = "auditlog" AUDITLOG_NG = "auditlog_ng" + BOOTSTRAP = "bootstrap" DATA_ANONYMIZATION = "data_anonymization" DESTINATION = "destination" DMS = "dms" diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index 6472918c..578bfc8d 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -139,6 +139,9 @@ class Operation(str, Enum): ADMS_CONFIG_GET_APP_TENANT = "config_get_app_tenant" ADMS_CONFIG_DELETE_APP_TENANT = "config_delete_app_tenant" + # Bootstrap Operations + BOOTSTRAP = "bootstrap" + # AI Core Operations AICORE_SET_CONFIG = "set_aicore_config" AICORE_AUTO_INSTRUMENT = "auto_instrument" diff --git a/tests/core/unit/runtime_context/__init__.py b/tests/core/unit/runtime_context/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/core/unit/runtime_context/test_runtime_context.py b/tests/core/unit/runtime_context/test_runtime_context.py new file mode 100644 index 00000000..a57114dd --- /dev/null +++ b/tests/core/unit/runtime_context/test_runtime_context.py @@ -0,0 +1,367 @@ +"""Tests for sap_cloud_sdk.core.runtime_context.""" + +import pytest +from unittest.mock import MagicMock, patch + +from sap_cloud_sdk.core.runtime_context import ( + ContextKey, + ContextProvider, + DWC_SUBDOMAIN, + DWC_TENANT, + DWCContextProvider, + IASContextProvider, + RuntimeContext, + RequestEnvelope, + SAPTriggerContextProvider, + TRIGGER_TYPE, + get_context, +) +from sap_cloud_sdk.core.runtime_context._context import ( + async_sdk_context, + sdk_context, + set_context, +) +from sap_cloud_sdk.core.runtime_context.providers._ias import ( + GLOBAL_TENANT_ID, + TENANT_ID, + USER_ID, +) +from sap_cloud_sdk.core.runtime_context.starlette import _merge + +_PATCH_PARSE = "sap_cloud_sdk.core.runtime_context.providers._ias.parse_token" + + +# --------------------------------------------------------------------------- +# ContextKey +# --------------------------------------------------------------------------- + + +class TestContextKey: + def test_repr(self): + key = ContextKey[str]("my_key") + assert repr(key) == "ContextKey('my_key')" + + def test_different_instances_are_different_keys(self): + a = ContextKey[str]("x") + b = ContextKey[str]("x") + ctx = RuntimeContext({a: "from-a"}) + assert ctx.get(a) == "from-a" + assert ctx.get(b) is None + + +# --------------------------------------------------------------------------- +# RuntimeContext +# --------------------------------------------------------------------------- + + +class TestRuntimeContext: + def test_empty_by_default(self): + ctx = RuntimeContext() + key = ContextKey[str]("k") + assert ctx.get(key) is None + + def test_get_returns_set_value(self): + key = ContextKey[str]("k") + ctx = RuntimeContext({key: "v"}) + assert ctx.get(key) == "v" + + def test_with_value_returns_new_instance(self): + key = ContextKey[str]("k") + ctx = RuntimeContext() + ctx2 = ctx.with_value(key, "v") + assert ctx2 is not ctx + assert ctx.get(key) is None + assert ctx2.get(key) == "v" + + def test_immutable_original_unaffected_by_with_value(self): + key = ContextKey[str]("k") + ctx = RuntimeContext({key: "original"}) + ctx.with_value(key, "new") + assert ctx.get(key) == "original" + + def test_repr(self): + key = ContextKey[str]("tenant_id") + ctx = RuntimeContext({key: "t-1"}) + assert "tenant_id" in repr(ctx) + assert "t-1" in repr(ctx) + + +# --------------------------------------------------------------------------- +# RequestEnvelope +# --------------------------------------------------------------------------- + + +class TestRequestEnvelope: + def test_defaults(self): + env = RequestEnvelope() + assert env.headers == {} + assert env.body is None + assert env.metadata == {} + + def test_headers_independent_per_instance(self): + a = RequestEnvelope() + b = RequestEnvelope() + a.headers["x"] = "1" + assert "x" not in b.headers + + +# --------------------------------------------------------------------------- +# get_context / set_context +# --------------------------------------------------------------------------- + + +class TestGetSetContext: + def test_get_returns_empty_by_default(self): + key = ContextKey[str]("k") + assert get_context().get(key) is None + + def test_set_then_get_returns_same_object(self): + key = ContextKey[str]("k") + ctx = RuntimeContext({key: "v"}) + set_context(ctx) + assert get_context() is ctx + + def teardown_method(self): + set_context(RuntimeContext()) + + +# --------------------------------------------------------------------------- +# sdk_context (sync) +# --------------------------------------------------------------------------- + + +class TestSdkContext: + def test_sets_context_inside_block(self): + key = ContextKey[str]("k") + ctx = RuntimeContext({key: "inside"}) + with sdk_context(ctx): + assert get_context().get(key) == "inside" + + def test_restores_previous_context_after_block(self): + key = ContextKey[str]("k") + outer = RuntimeContext({key: "outer"}) + set_context(outer) + with sdk_context(RuntimeContext({key: "inner"})): + pass + assert get_context().get(key) == "outer" + + def test_restores_on_exception(self): + key = ContextKey[str]("k") + outer = RuntimeContext({key: "outer"}) + set_context(outer) + with pytest.raises(ValueError): + with sdk_context(RuntimeContext({key: "inner"})): + raise ValueError("boom") + assert get_context().get(key) == "outer" + + def test_yields_the_context(self): + ctx = RuntimeContext() + with sdk_context(ctx) as yielded: + assert yielded is ctx + + def teardown_method(self): + set_context(RuntimeContext()) + + +# --------------------------------------------------------------------------- +# async_sdk_context +# --------------------------------------------------------------------------- + + +class TestAsyncSdkContext: + @pytest.mark.anyio + async def test_sets_context_inside_async_block(self): + key = ContextKey[str]("k") + ctx = RuntimeContext({key: "async-value"}) + async with async_sdk_context(ctx): + assert get_context().get(key) == "async-value" + + @pytest.mark.anyio + async def test_restores_after_async_block(self): + key = ContextKey[str]("k") + outer = RuntimeContext({key: "outer"}) + set_context(outer) + async with async_sdk_context(RuntimeContext({key: "inner"})): + pass + assert get_context().get(key) == "outer" + + @pytest.mark.anyio + async def test_restores_on_async_exception(self): + key = ContextKey[str]("k") + outer = RuntimeContext({key: "outer"}) + set_context(outer) + with pytest.raises(RuntimeError): + async with async_sdk_context(RuntimeContext({key: "inner"})): + raise RuntimeError("boom") + assert get_context().get(key) == "outer" + + def teardown_method(self): + set_context(RuntimeContext()) + + +# --------------------------------------------------------------------------- +# ContextProvider protocol +# --------------------------------------------------------------------------- + + +class TestContextProviderProtocol: + def test_custom_class_satisfies_protocol(self): + class MyProvider: + def extract(self, envelope: RequestEnvelope) -> RuntimeContext: + return RuntimeContext() + + assert isinstance(MyProvider(), ContextProvider) + + def test_class_without_extract_does_not_satisfy_protocol(self): + class NotAProvider: + pass + + assert not isinstance(NotAProvider(), ContextProvider) + + +# --------------------------------------------------------------------------- +# IASContextProvider +# --------------------------------------------------------------------------- + + +def _make_claims(app_tid=None, user_uuid=None, sap_gtid=None): + claims = MagicMock() + claims.app_tid = app_tid + claims.user_uuid = user_uuid + claims.sap_gtid = sap_gtid + return claims + + +def _make_envelope(headers: dict) -> RequestEnvelope: + return RequestEnvelope(headers=headers) + + +class TestIASContextProvider: + def test_extracts_tenant_and_user(self): + claims = _make_claims(app_tid="t-1", user_uuid="u-1") + envelope = _make_envelope({"authorization": "Bearer tok"}) + with patch(_PATCH_PARSE, return_value=claims): + ctx = IASContextProvider().extract(envelope) + assert ctx.get(TENANT_ID) == "t-1" + assert ctx.get(USER_ID) == "u-1" + + def test_does_not_set_trigger_type(self): + claims = _make_claims(app_tid="t-1", user_uuid="u-1") + envelope = _make_envelope( + {"authorization": "Bearer tok", "x-sap-origin": "ui5"} + ) + with patch(_PATCH_PARSE, return_value=claims): + ctx = IASContextProvider().extract(envelope) + assert ctx.get(TRIGGER_TYPE) is None + + def test_extracts_global_tenant_id(self): + claims = _make_claims(app_tid="t-1", user_uuid="u-1", sap_gtid="g-1") + envelope = _make_envelope({"authorization": "Bearer tok"}) + with patch(_PATCH_PARSE, return_value=claims): + ctx = IASContextProvider().extract(envelope) + assert ctx.get(GLOBAL_TENANT_ID) == "g-1" + + def test_returns_empty_context_when_no_auth_header(self): + envelope = _make_envelope({}) + ctx = IASContextProvider().extract(envelope) + assert ctx.get(TENANT_ID) is None + assert ctx.get(USER_ID) is None + assert ctx.get(GLOBAL_TENANT_ID) is None + + def test_returns_empty_context_on_parse_error(self): + envelope = _make_envelope({"authorization": "Bearer bad"}) + with patch(_PATCH_PARSE, side_effect=ValueError("bad")): + ctx = IASContextProvider().extract(envelope) + assert ctx.get(TENANT_ID) is None + assert ctx.get(USER_ID) is None + + def test_tenant_id_none_when_claim_absent(self): + claims = _make_claims(app_tid=None, user_uuid="u-1") + envelope = _make_envelope({"authorization": "Bearer tok"}) + with patch(_PATCH_PARSE, return_value=claims): + ctx = IASContextProvider().extract(envelope) + assert ctx.get(TENANT_ID) is None + assert ctx.get(USER_ID) == "u-1" + + def test_satisfies_context_provider_protocol(self): + assert isinstance(IASContextProvider(), ContextProvider) + + +# --------------------------------------------------------------------------- +# SAPTriggerContextProvider +# --------------------------------------------------------------------------- + + +class TestSAPTriggerContextProvider: + def test_extracts_trigger_type(self): + envelope = _make_envelope({"x-sap-origin": "ui5"}) + ctx = SAPTriggerContextProvider().extract(envelope) + assert ctx.get(TRIGGER_TYPE) == "ui5" + + def test_trigger_type_none_when_header_absent(self): + envelope = _make_envelope({}) + ctx = SAPTriggerContextProvider().extract(envelope) + assert ctx.get(TRIGGER_TYPE) is None + + def test_satisfies_context_provider_protocol(self): + assert isinstance(SAPTriggerContextProvider(), ContextProvider) + + +# --------------------------------------------------------------------------- +# DWCContextProvider +# --------------------------------------------------------------------------- + + +class TestDWCContextProvider: + def test_extracts_dwc_subdomain(self): + envelope = _make_envelope({"dwc-subdomain": "my-subdomain"}) + ctx = DWCContextProvider().extract(envelope) + assert ctx.get(DWC_SUBDOMAIN) == "my-subdomain" + + def test_extracts_dwc_tenant(self): + envelope = _make_envelope({"dwc-tenant": "my-tenant"}) + ctx = DWCContextProvider().extract(envelope) + assert ctx.get(DWC_TENANT) == "my-tenant" + + def test_returns_empty_when_no_headers(self): + envelope = _make_envelope({}) + ctx = DWCContextProvider().extract(envelope) + assert ctx.get(DWC_SUBDOMAIN) is None + assert ctx.get(DWC_TENANT) is None + + def test_satisfies_context_provider_protocol(self): + assert isinstance(DWCContextProvider(), ContextProvider) + + +# --------------------------------------------------------------------------- +# _merge +# --------------------------------------------------------------------------- + + +class TestMerge: + def test_first_writer_wins_per_key(self): + key = ContextKey[str]("k") + a = RuntimeContext({key: "from-a"}) + b = RuntimeContext({key: "from-b"}) + merged = _merge([a, b]) + assert merged.get(key) == "from-a" + + def test_second_fills_missing_from_first(self): + k1 = ContextKey[str]("k1") + k2 = ContextKey[str]("k2") + a = RuntimeContext({k1: "v1"}) + b = RuntimeContext({k2: "v2"}) + merged = _merge([a, b]) + assert merged.get(k1) == "v1" + assert merged.get(k2) == "v2" + + def test_empty_list_returns_empty_context(self): + key = ContextKey[str]("k") + merged = _merge([]) + assert merged.get(key) is None + + def test_single_context_passthrough(self): + key = ContextKey[str]("k") + ctx = RuntimeContext({key: "v"}) + merged = _merge([ctx]) + assert merged.get(key) == "v" diff --git a/tests/core/unit/telemetry/test_module.py b/tests/core/unit/telemetry/test_module.py index ccc5d1dc..9c0c3e15 100644 --- a/tests/core/unit/telemetry/test_module.py +++ b/tests/core/unit/telemetry/test_module.py @@ -55,7 +55,7 @@ def test_module_in_collection(self): def test_all_modules_present(self): """Test that all expected modules are present.""" all_modules = list(Module) - assert len(all_modules) == 13 + assert len(all_modules) == 14 assert Module.ADMS in all_modules assert Module.AGENT_MEMORY in all_modules assert Module.AGENTGATEWAY in all_modules diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index 8db63ca3..0ac54300 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -216,5 +216,5 @@ def test_operation_count(self): all_operations = list(Operation) # 3 auditlog + 12 destination + 10 certificate + 10 fragment + 8 objectstore # + 2 extensibility + 4 aicore + 23 dms + 6 agentgateway + 13 agent_memory - # + 5 data_anonymization + 52 adms + 6 print = 154 - assert len(all_operations) == 154 + # + 5 data_anonymization + 52 adms + 6 print + 1 bootstrap = 155 + assert len(all_operations) == 155 diff --git a/uv.lock b/uv.lock index c4d7eca3..2c04aa6e 100644 --- a/uv.lock +++ b/uv.lock @@ -3685,7 +3685,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.36.0" +version = "0.39.0" source = { editable = "." } dependencies = [ { name = "grpcio" },