Skip to content

Commit d9788f6

Browse files
committed
feat: OData reusable transport
1 parent 581a6b1 commit d9788f6

21 files changed

Lines changed: 2093 additions & 1 deletion
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Shared OData v4 abstractions for the SAP Cloud SDK."""
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""Asynchronous HTTP transport for OData v4 services."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import logging
7+
from typing import Any
8+
9+
import httpx
10+
11+
from sap_cloud_sdk.core.odata.exceptions import (
12+
ODataAuthError,
13+
ODataCsrfError,
14+
ODataNotFoundError,
15+
ODataRequestError,
16+
)
17+
18+
logger = logging.getLogger(__name__)
19+
20+
_CSRF_HEADER = "X-CSRF-Token"
21+
_CSRF_FETCH_VALUE = "Fetch"
22+
_CSRF_FETCH_TIMEOUT = 10
23+
_REQUEST_TIMEOUT = 30
24+
25+
26+
class AsyncODataHttpTransport:
27+
"""Asynchronous HTTP transport for OData v4 services.
28+
29+
Mirrors :class:`~sap_cloud_sdk.core.odata._transport.ODataHttpTransport`
30+
but uses ``httpx.AsyncClient``. Use as an async context manager::
31+
32+
async with AsyncODataHttpTransport(base_url, client) as t:
33+
data = await t.get("BusinessPartnerSet")
34+
35+
Args:
36+
base_url: Root URL of the OData service.
37+
client: Pre-configured ``httpx.AsyncClient``.
38+
csrf_enabled: Whether to fetch and attach CSRF tokens on mutating
39+
requests.
40+
"""
41+
42+
def __init__(
43+
self,
44+
base_url: str,
45+
client: httpx.AsyncClient,
46+
csrf_enabled: bool = True,
47+
) -> None:
48+
self._base_url = base_url.rstrip("/")
49+
self._client = client
50+
self._csrf_enabled = csrf_enabled
51+
self._csrf_token: str | None = None
52+
self._csrf_lock = asyncio.Lock()
53+
54+
async def __aenter__(self) -> "AsyncODataHttpTransport":
55+
return self
56+
57+
async def __aexit__(self, *args: Any) -> None:
58+
await self._client.aclose()
59+
60+
# ------------------------------------------------------------------
61+
# Public verbs
62+
# ------------------------------------------------------------------
63+
64+
async def get(
65+
self, path: str, params: dict[str, Any] | None = None
66+
) -> dict[str, Any]:
67+
"""Execute a GET and return the parsed JSON body."""
68+
return await self._request("GET", path, params=params)
69+
70+
async def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
71+
"""Execute a POST with CSRF and return the parsed JSON body."""
72+
return await self._send_with_csrf("POST", path, json=body)
73+
74+
async def patch(
75+
self,
76+
path: str,
77+
body: dict[str, Any],
78+
etag: str | None = None,
79+
) -> dict[str, Any]:
80+
extra: dict[str, str] = {}
81+
if etag is not None:
82+
extra["If-Match"] = etag
83+
return await self._send_with_csrf("PATCH", path, json=body, extra_headers=extra)
84+
85+
async def put(
86+
self,
87+
path: str,
88+
body: dict[str, Any],
89+
etag: str | None = None,
90+
) -> dict[str, Any]:
91+
extra: dict[str, str] = {}
92+
if etag is not None:
93+
extra["If-Match"] = etag
94+
return await self._send_with_csrf("PUT", path, json=body, extra_headers=extra)
95+
96+
async def delete(self, path: str, etag: str | None = None) -> None:
97+
extra: dict[str, str] = {}
98+
if etag is not None:
99+
extra["If-Match"] = etag
100+
await self._send_with_csrf("DELETE", path, extra_headers=extra)
101+
102+
def absolute_url(self, path: str) -> str:
103+
return self._base_url + "/" + path.lstrip("/")
104+
105+
# ------------------------------------------------------------------
106+
# Internal
107+
# ------------------------------------------------------------------
108+
109+
async def _send_with_csrf(
110+
self,
111+
method: str,
112+
path: str,
113+
*,
114+
json: Any | None = None,
115+
extra_headers: dict[str, str] | None = None,
116+
) -> dict[str, Any]:
117+
csrf_headers: dict[str, str] = {}
118+
if self._csrf_enabled:
119+
csrf_headers[_CSRF_HEADER] = await self._get_csrf_token()
120+
121+
merged = {**(extra_headers or {}), **csrf_headers}
122+
try:
123+
return await self._request(method, path, json=json, extra_headers=merged)
124+
except ODataAuthError as exc:
125+
if exc.status_code == 403 and self._csrf_enabled:
126+
await self._invalidate_csrf_token()
127+
csrf_headers[_CSRF_HEADER] = await self._get_csrf_token()
128+
merged = {**(extra_headers or {}), **csrf_headers}
129+
return await self._request(method, path, json=json, extra_headers=merged)
130+
raise
131+
132+
async def _get_csrf_token(self) -> str:
133+
async with self._csrf_lock:
134+
if self._csrf_token is not None:
135+
return self._csrf_token
136+
137+
token = await self._fetch_csrf_token()
138+
async with self._csrf_lock:
139+
if self._csrf_token is None:
140+
self._csrf_token = token
141+
return self._csrf_token # type: ignore[return-value]
142+
143+
async def _invalidate_csrf_token(self) -> None:
144+
async with self._csrf_lock:
145+
self._csrf_token = None
146+
147+
async def _fetch_csrf_token(self) -> str:
148+
url = self._base_url + "/"
149+
try:
150+
resp = await self._client.get(
151+
url,
152+
headers={_CSRF_HEADER: _CSRF_FETCH_VALUE},
153+
timeout=_CSRF_FETCH_TIMEOUT,
154+
)
155+
except httpx.RequestError as exc:
156+
raise ODataCsrfError(f"Async CSRF fetch failed: {exc}") from exc
157+
158+
token = resp.headers.get(_CSRF_HEADER, "")
159+
if not token:
160+
raise ODataCsrfError(
161+
f"Service did not return a CSRF token (HTTP {resp.status_code})"
162+
)
163+
return token
164+
165+
async def _request(
166+
self,
167+
method: str,
168+
path: str,
169+
*,
170+
params: dict[str, Any] | None = None,
171+
json: Any | None = None,
172+
extra_headers: dict[str, str] | None = None,
173+
) -> dict[str, Any]:
174+
url = self.absolute_url(path)
175+
headers: dict[str, str] = {
176+
"Accept": "application/json",
177+
"Content-Type": "application/json",
178+
}
179+
if extra_headers:
180+
headers.update(extra_headers)
181+
182+
logger.debug("%s %s params=%s", method, url, params)
183+
try:
184+
resp = await self._client.request(
185+
method=method,
186+
url=url,
187+
headers=headers,
188+
params=params,
189+
json=json,
190+
timeout=_REQUEST_TIMEOUT,
191+
)
192+
except httpx.RequestError as exc:
193+
raise ODataCsrfError(f"Request failed: {exc}") from exc
194+
195+
self._raise_for_status(resp)
196+
197+
if resp.status_code == 204 or not resp.content:
198+
return {}
199+
return resp.json()
200+
201+
def _raise_for_status(self, response: httpx.Response) -> None:
202+
if response.status_code == 404:
203+
raise ODataNotFoundError(_HttpxResponseAdapter(response))
204+
if response.status_code in (401, 403):
205+
raise ODataAuthError(_HttpxResponseAdapter(response))
206+
if not (200 <= response.status_code < 300):
207+
raise ODataRequestError(_HttpxResponseAdapter(response))
208+
209+
210+
class _HttpxResponseAdapter:
211+
"""Minimal adapter so httpx.Response can be passed to ODataRequestError."""
212+
213+
def __init__(self, response: httpx.Response) -> None:
214+
self.status_code = response.status_code
215+
self._response = response
216+
217+
def json(self) -> Any:
218+
return self._response.json()
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""CSRF token fetch-and-cache for OData v4 mutating requests."""
2+
3+
from __future__ import annotations
4+
5+
import threading
6+
from typing import TYPE_CHECKING
7+
8+
import requests as _requests
9+
10+
from sap_cloud_sdk.core.odata.exceptions import ODataCsrfError
11+
12+
if TYPE_CHECKING:
13+
from ._transport import ODataHttpTransport
14+
15+
_CSRF_HEADER = "X-CSRF-Token"
16+
_CSRF_FETCH_VALUE = "Fetch"
17+
_FETCH_TIMEOUT = 10
18+
19+
20+
class CsrfTokenProvider:
21+
"""Fetch and cache a CSRF token for one OData service root.
22+
23+
The token is fetched lazily on the first mutating request and cached
24+
until it is invalidated (typically after a 403 response).
25+
26+
Thread-safe: internal state is protected by a :class:`threading.Lock`.
27+
28+
Args:
29+
transport: The owning :class:`ODataHttpTransport` whose session and
30+
base URL are used to perform the CSRF-fetch GET.
31+
"""
32+
33+
def __init__(self, transport: "ODataHttpTransport") -> None:
34+
self._transport = transport
35+
self._token: str | None = None
36+
self._lock = threading.Lock()
37+
38+
def get(self) -> str:
39+
"""Return the cached CSRF token, fetching from the service if needed."""
40+
with self._lock:
41+
if self._token is not None:
42+
return self._token
43+
44+
token = self._fetch()
45+
with self._lock:
46+
if self._token is None:
47+
self._token = token
48+
return self._token
49+
50+
def invalidate(self) -> None:
51+
"""Discard the cached token so the next call re-fetches."""
52+
with self._lock:
53+
self._token = None
54+
55+
def _fetch(self) -> str:
56+
url = self._transport._base_url + "/"
57+
try:
58+
resp = self._transport._session.get(
59+
url,
60+
headers={_CSRF_HEADER: _CSRF_FETCH_VALUE},
61+
timeout=_FETCH_TIMEOUT,
62+
)
63+
except _requests.RequestException as exc:
64+
raise ODataCsrfError(f"CSRF fetch failed: {exc}") from exc
65+
66+
token = resp.headers.get(_CSRF_HEADER, "")
67+
if not token:
68+
raise ODataCsrfError(
69+
f"Service did not return a CSRF token (HTTP {resp.status_code})"
70+
)
71+
return token
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Filter expression DSL for OData v4 $filter query options."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
8+
def _format_value(value: Any) -> str:
9+
"""Serialise a Python value to an OData v4 literal."""
10+
if isinstance(value, bool):
11+
return "true" if value else "false"
12+
if isinstance(value, str):
13+
return "'" + value.replace("'", "''") + "'"
14+
return str(value)
15+
16+
17+
class FilterExpression:
18+
"""Composable OData v4 ``$filter`` expression.
19+
20+
Build expressions via :meth:`field` and combine them with :meth:`and_`,
21+
:meth:`or_`, and :meth:`not_`::
22+
23+
f = (
24+
FilterExpression.field("Price").gt(100)
25+
.and_(FilterExpression.field("Category").eq("Books"))
26+
)
27+
str(f) # "(Price gt 100) and (Category eq 'Books')"
28+
"""
29+
30+
__slots__ = ("_expr",)
31+
32+
def __init__(self, expr: str) -> None:
33+
self._expr = expr
34+
35+
@staticmethod
36+
def field(name: str) -> "_FieldRef":
37+
"""Start a comparison expression for *name*."""
38+
return _FieldRef(name)
39+
40+
def and_(self, other: "FilterExpression") -> "FilterExpression":
41+
return FilterExpression(f"({self._expr}) and ({other._expr})")
42+
43+
def or_(self, other: "FilterExpression") -> "FilterExpression":
44+
return FilterExpression(f"({self._expr}) or ({other._expr})")
45+
46+
def not_(self) -> "FilterExpression":
47+
return FilterExpression(f"not ({self._expr})")
48+
49+
def __str__(self) -> str:
50+
return self._expr
51+
52+
def __repr__(self) -> str:
53+
return f"FilterExpression({self._expr!r})"
54+
55+
def __eq__(self, other: object) -> bool:
56+
if isinstance(other, FilterExpression):
57+
return self._expr == other._expr
58+
return NotImplemented
59+
60+
def __hash__(self) -> int:
61+
return hash(self._expr)
62+
63+
64+
class _FieldRef:
65+
"""Intermediate object: a field name awaiting a comparison operator."""
66+
67+
__slots__ = ("_name",)
68+
69+
def __init__(self, name: str) -> None:
70+
self._name = name
71+
72+
def eq(self, value: Any) -> FilterExpression:
73+
return FilterExpression(f"{self._name} eq {_format_value(value)}")
74+
75+
def ne(self, value: Any) -> FilterExpression:
76+
return FilterExpression(f"{self._name} ne {_format_value(value)}")
77+
78+
def lt(self, value: Any) -> FilterExpression:
79+
return FilterExpression(f"{self._name} lt {_format_value(value)}")
80+
81+
def le(self, value: Any) -> FilterExpression:
82+
return FilterExpression(f"{self._name} le {_format_value(value)}")
83+
84+
def gt(self, value: Any) -> FilterExpression:
85+
return FilterExpression(f"{self._name} gt {_format_value(value)}")
86+
87+
def ge(self, value: Any) -> FilterExpression:
88+
return FilterExpression(f"{self._name} ge {_format_value(value)}")
89+
90+
def contains(self, value: str) -> FilterExpression:
91+
return FilterExpression(f"contains({self._name}, {_format_value(value)})")
92+
93+
def starts_with(self, value: str) -> FilterExpression:
94+
return FilterExpression(f"startswith({self._name}, {_format_value(value)})")
95+
96+
def ends_with(self, value: str) -> FilterExpression:
97+
return FilterExpression(f"endswith({self._name}, {_format_value(value)})")

0 commit comments

Comments
 (0)