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/bootstrap.py b/src/sap_cloud_sdk/core/bootstrap.py new file mode 100644 index 00000000..9de279cf --- /dev/null +++ b/src/sap_cloud_sdk/core/bootstrap.py @@ -0,0 +1,51 @@ +"""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 HeaderContextProvider, IASContextProvider + + +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(), HeaderContextProvider()]``. + + Raises: + TypeError: If no registered adapter recognises *app*. + + Example:: + + from sap_cloud_sdk import bootstrap + + bootstrap(app) # IAS + SAP headers by default + + # custom providers: + bootstrap(app, providers=[IASContextProvider(), MyProvider()]) + """ + if not providers: + providers = [IASContextProvider(), HeaderContextProvider()] + + 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..9ca19916 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/__init__.py @@ -0,0 +1,54 @@ +"""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 + HeaderContextProvider + +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, 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 ( + HeaderContextProvider, + IASContextProvider, + 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", + "FrameworkAdapter", + "GLOBAL_TENANT_ID", + "HeaderContextProvider", + "IASContextProvider", + "RuntimeContext", + "RequestEnvelope", + "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..5d139a41 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/_keys.py @@ -0,0 +1,30 @@ +"""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`. + + Example:: + + MY_KEY = ContextKey[str]("my_key") + + ctx = RuntimeContext({MY_KEY: "value"}) + ctx.get(MY_KEY) # -> "value" + """ + + 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") 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..28e45cbe --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/_registry.py @@ -0,0 +1,55 @@ +"""Framework adapter base class and registry for bootstrap().""" + +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..f457301e --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/providers/__init__.py @@ -0,0 +1,17 @@ +"""Built-in context providers.""" + +from sap_cloud_sdk.core.runtime_context.providers._headers import HeaderContextProvider +from sap_cloud_sdk.core.runtime_context.providers._ias import ( + GLOBAL_TENANT_ID, + IASContextProvider, + TENANT_ID, + USER_ID, +) + +__all__ = [ + "GLOBAL_TENANT_ID", + "HeaderContextProvider", + "IASContextProvider", + "TENANT_ID", + "USER_ID", +] diff --git a/src/sap_cloud_sdk/core/runtime_context/providers/_headers.py b/src/sap_cloud_sdk/core/runtime_context/providers/_headers.py new file mode 100644 index 00000000..428e1690 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/providers/_headers.py @@ -0,0 +1,23 @@ +"""SAP standard headers 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 HeaderContextProvider(ContextProvider): + """Extracts context from plain request headers. + + Defines and populates the following context keys: + + - :data:`~sap_cloud_sdk.core.runtime_context.TRIGGER_TYPE` + from ``x-sap-origin`` + """ + + def extract(self, envelope: RequestEnvelope) -> RuntimeContext: + values = {} + origin = envelope.headers.get("x-sap-origin") + if origin: + values[TRIGGER_TYPE] = origin + 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..798095df --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/providers/_ias.py @@ -0,0 +1,46 @@ +"""IAS context provider and its typed keys.""" + +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 + +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: + pass + + 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/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..8abd1259 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/user-guide.md @@ -0,0 +1,160 @@ +# 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) and +`HeaderContextProvider` (reads SAP standard headers like `x-sap-origin`). + +### 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, IAS_CLAIMS + +# 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`, `IAS_CLAIMS` | +| `HeaderContextProvider` | `x-sap-origin` | `TRIGGER_TYPE` | + +### 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, HeaderContextProvider + +bootstrap(app, providers=[IASContextProvider(), HeaderContextProvider(), 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/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..e9fb7b15 --- /dev/null +++ b/tests/core/unit/runtime_context/test_runtime_context.py @@ -0,0 +1,338 @@ +"""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, + HeaderContextProvider, + IASContextProvider, + RuntimeContext, + RequestEnvelope, + 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) + + +# --------------------------------------------------------------------------- +# HeaderContextProvider +# --------------------------------------------------------------------------- + + +class TestHeaderContextProvider: + def test_extracts_trigger_type(self): + envelope = _make_envelope({"x-sap-origin": "ui5"}) + ctx = HeaderContextProvider().extract(envelope) + assert ctx.get(TRIGGER_TYPE) == "ui5" + + def test_trigger_type_none_when_header_absent(self): + envelope = _make_envelope({}) + ctx = HeaderContextProvider().extract(envelope) + assert ctx.get(TRIGGER_TYPE) is None + + def test_satisfies_context_provider_protocol(self): + assert isinstance(HeaderContextProvider(), 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/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" },