Skip to content

Commit 32d6dd8

Browse files
Extensible providers
1 parent 72dacd5 commit 32d6dd8

7 files changed

Lines changed: 179 additions & 70 deletions

File tree

src/sap_cloud_sdk/core/bootstrap.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
"""Top-level bootstrap() entry point for the SAP Cloud SDK."""
22

3-
from typing import Any
3+
from typing import Any, List, Optional
44

55
from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider
66

77

8-
def bootstrap(app: Any, provider: ContextProvider) -> None:
8+
def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> None:
99
"""Wire the SDK runtime context into your application framework.
1010
1111
Call this once at application startup. The SDK will attach a middleware
1212
to *app* that populates :class:`~sap_cloud_sdk.core.runtime_context.RequestContext`
13-
on every inbound request using *provider*.
13+
on every inbound request by running all *providers* against it and merging
14+
the results.
1415
1516
After bootstrapping, any SDK module (auditlog, telemetry, etc.) can call
1617
:func:`~sap_cloud_sdk.core.runtime_context.get_context` to read
@@ -20,36 +21,36 @@ def bootstrap(app: Any, provider: ContextProvider) -> None:
2021
- Starlette / FastAPI
2122
2223
Args:
23-
app: The application instance to attach the middleware to.
24-
provider: A :class:`~sap_cloud_sdk.core.runtime_context.ContextProvider`
25-
that knows how to extract context from a request in this framework.
24+
app: The application instance to attach the middleware to.
25+
providers: One or more :class:`~sap_cloud_sdk.core.runtime_context.ContextProvider`
26+
instances. Defaults to ``[IASContextProvider()]``.
2627
2728
Raises:
2829
TypeError: If *app* is not a recognised framework application type.
2930
3031
Example::
3132
32-
from starlette.applications import Starlette
3333
from sap_cloud_sdk import bootstrap
34-
from sap_cloud_sdk.core.runtime_context import IASContextProvider
3534
36-
app = Starlette(...)
37-
bootstrap(app, provider=IASContextProvider())
35+
bootstrap(app) # IASContextProvider by default
36+
37+
# multiple providers:
38+
bootstrap(app, providers=[IASContextProvider(), MyCustomProvider()])
3839
"""
39-
_attach(app, provider)
40+
if not providers:
41+
from sap_cloud_sdk.core.runtime_context import IASContextProvider
42+
providers = [IASContextProvider()]
43+
_attach(app, providers)
4044

4145

42-
def _attach(app: Any, provider: ContextProvider) -> None:
46+
def _attach(app: Any, providers: List[ContextProvider]) -> None:
4347
"""Detect framework and register the appropriate context middleware."""
44-
# Starlette / FastAPI — both expose add_middleware and share the same base
4548
try:
4649
from starlette.applications import Starlette
47-
from sap_cloud_sdk.core.runtime_context.starlette import (
48-
StarletteContextMiddleware,
49-
)
50+
from sap_cloud_sdk.core.runtime_context.starlette import StarletteContextMiddleware
5051

5152
if isinstance(app, Starlette):
52-
app.add_middleware(StarletteContextMiddleware, provider=provider)
53+
app.add_middleware(StarletteContextMiddleware, providers=providers)
5354
return
5455
except ImportError:
5556
pass

src/sap_cloud_sdk/core/runtime_context/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,15 @@
3434
sdk_context,
3535
set_context,
3636
)
37+
from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope
3738
from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider
3839
from sap_cloud_sdk.core.runtime_context._providers import IASContextProvider
3940

4041
__all__ = [
4142
"ContextProvider",
4243
"IASContextProvider",
4344
"RequestContext",
45+
"RequestEnvelope",
4446
"async_sdk_context",
4547
"get_context",
4648
"sdk_context",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Framework-agnostic request envelope passed to ContextProviders."""
2+
3+
from dataclasses import dataclass, field
4+
from typing import Any, Dict, Optional
5+
6+
7+
@dataclass
8+
class RequestEnvelope:
9+
"""Normalized view of an inbound request, independent of framework.
10+
11+
Framework middlewares build this from their native request object.
12+
:class:`~sap_cloud_sdk.core.runtime_context.ContextProvider` implementations
13+
read from it — they never touch framework-specific types.
14+
15+
Attributes:
16+
headers: Case-insensitive HTTP headers (or equivalent for gRPC/etc.).
17+
body: Raw request body bytes. ``None`` if not extracted.
18+
metadata: Catch-all for framework extras (query params, gRPC metadata,
19+
connection info, etc.). Reserved for future providers.
20+
"""
21+
22+
headers: Dict[str, str] = field(default_factory=dict)
23+
body: Optional[bytes] = field(default=None)
24+
metadata: Dict[str, Any] = field(default_factory=dict)
Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,29 @@
11
"""ContextProvider protocol — the pluggable extraction interface."""
22

3-
from typing import Any, Protocol, runtime_checkable
3+
from typing import Protocol, runtime_checkable
44

55
from sap_cloud_sdk.core.runtime_context._context import RequestContext
6+
from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope
67

78

89
@runtime_checkable
910
class ContextProvider(Protocol):
10-
"""Extract a :class:`RequestContext` from a framework-specific request object.
11+
"""Extract a :class:`RequestContext` from a :class:`RequestEnvelope`.
1112
1213
Implement this protocol to teach the SDK how to read caller-identity
13-
information from a new framework (Flask, gRPC, raw WSGI, etc.).
14+
information from a specific auth provider (IAS, XSUAA, etc.).
15+
16+
The envelope is framework-agnostic — providers never touch Starlette,
17+
Flask, or gRPC types directly. The framework middleware is responsible
18+
for building the envelope.
1419
1520
Example::
1621
1722
class MyProvider(ContextProvider):
18-
def extract(self, request: MyRequest) -> RequestContext:
19-
return RequestContext(tenant_id=request.tenant, user_id=request.user)
23+
def extract(self, envelope: RequestEnvelope) -> RequestContext:
24+
token = envelope.headers.get("x-my-token", "")
25+
return RequestContext(tenant_id=decode(token).tenant)
2026
"""
2127

22-
def extract(self, request: Any) -> RequestContext: # pragma: no cover
28+
def extract(self, envelope: RequestEnvelope) -> RequestContext: # pragma: no cover
2329
...

src/sap_cloud_sdk/core/runtime_context/_providers.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,29 @@
11
"""Built-in ContextProvider implementations."""
22

3-
from typing import Any
4-
53
from sap_cloud_sdk.core.runtime_context._context import RequestContext
4+
from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope
65
from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider
76
from sap_cloud_sdk.ias import parse_token
87

98

109
class IASContextProvider(ContextProvider):
1110
"""Extracts tenant/user context from an IAS JWT ``Authorization`` header.
1211
13-
Works with any request object that exposes headers via ``request.headers``
14-
(Starlette, FastAPI, etc.).
12+
Reads from a :class:`RequestEnvelope` — works with any framework.
1513
1614
The following claims are mapped:
17-
- ``app_tid`` → :attr:`RequestContext.tenant_id`
18-
- ``user_uuid`` → :attr:`RequestContext.user_id`
19-
- ``x-sap-origin`` header → :attr:`RequestContext.trigger_type`
15+
16+
- ``app_tid`` → :attr:`~.RequestContext.tenant_id`
17+
- ``user_uuid`` → :attr:`~.RequestContext.user_id`
18+
- ``x-sap-origin`` → :attr:`~.RequestContext.trigger_type`
2019
2120
Full :class:`~sap_cloud_sdk.ias.IASClaims` are stored under
2221
``RequestContext.extras["ias.claims"]`` for modules that need them.
2322
"""
2423

25-
def extract(self, request: Any) -> RequestContext:
26-
headers = getattr(request, "headers", {})
27-
auth = headers.get("authorization", "") or headers.get("Authorization", "")
28-
origin = headers.get("x-sap-origin") or headers.get("X-Sap-Origin")
24+
def extract(self, envelope: RequestEnvelope) -> RequestContext:
25+
auth = envelope.headers.get("authorization", "")
26+
origin = envelope.headers.get("x-sap-origin")
2927

3028
claims = None
3129
if auth:
Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Starlette/FastAPI context middleware."""
22

3-
from typing import Any
3+
from typing import Any, List, Optional
44

5-
from sap_cloud_sdk.core.runtime_context._context import async_sdk_context
5+
from sap_cloud_sdk.core.runtime_context._context import RequestContext, async_sdk_context
6+
from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope
67
from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider
78

89
try:
@@ -16,34 +17,48 @@
1617
) from exc
1718

1819

20+
def _merge(contexts: List[RequestContext]) -> RequestContext:
21+
"""Merge multiple RequestContexts — first non-None value wins per field."""
22+
merged = RequestContext()
23+
for ctx in contexts:
24+
if merged.tenant_id is None:
25+
merged.tenant_id = ctx.tenant_id
26+
if merged.user_id is None:
27+
merged.user_id = ctx.user_id
28+
if merged.trigger_type is None:
29+
merged.trigger_type = ctx.trigger_type
30+
merged.extras.update(ctx.extras)
31+
return merged
32+
33+
1934
class StarletteContextMiddleware(BaseHTTPMiddleware):
2035
"""Starlette/FastAPI middleware that populates the SDK runtime context.
2136
22-
Runs *provider*.extract() on every inbound request and makes the result
23-
available via :func:`~sap_cloud_sdk.core.runtime_context.get_context`
24-
for the duration of that request.
37+
Builds a :class:`~sap_cloud_sdk.core.runtime_context.RequestEnvelope` from
38+
each inbound request, runs all *providers* against it, and merges the results
39+
into a single :class:`~sap_cloud_sdk.core.runtime_context.RequestContext`
40+
available via :func:`~sap_cloud_sdk.core.runtime_context.get_context` for
41+
the duration of that request.
42+
43+
First non-None value wins per field when merging. Extras are union-merged
44+
(later providers can add keys, but not overwrite earlier ones).
2545
2646
Usage::
2747
28-
from starlette.applications import Starlette
2948
from sap_cloud_sdk import bootstrap
3049
31-
app = Starlette(...)
32-
bootstrap(app)
33-
34-
Or manually::
35-
36-
from sap_cloud_sdk.core.runtime_context.starlette import StarletteContextMiddleware
37-
from sap_cloud_sdk.core.runtime_context import IASContextProvider
50+
bootstrap(app) # IASContextProvider by default
3851
39-
app.add_middleware(StarletteContextMiddleware, provider=IASContextProvider())
52+
# or with multiple providers:
53+
bootstrap(app, providers=[IASContextProvider(), MyCustomProvider()])
4054
"""
4155

42-
def __init__(self, app: Any, provider: ContextProvider) -> None:
56+
def __init__(self, app: Any, providers: List[ContextProvider]) -> None:
4357
super().__init__(app)
44-
self._provider = provider
58+
self._providers = providers
4559

4660
async def dispatch(self, request: Request, call_next: Any) -> Response:
47-
ctx = self._provider.extract(request)
61+
envelope = RequestEnvelope(headers=dict(request.headers))
62+
ctx = _merge([p.extract(envelope) for p in self._providers])
4863
async with async_sdk_context(ctx):
4964
return await call_next(request)

0 commit comments

Comments
 (0)