Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sap-cloud-sdk"
version = "0.27.1"
version = "0.28.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
48 changes: 48 additions & 0 deletions src/sap_cloud_sdk/core/odata/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Shared OData v4 abstractions for the SAP Cloud SDK."""

from sap_cloud_sdk.core.odata._async_transport import AsyncODataHttpTransport
from sap_cloud_sdk.core.odata._factory import odata_transport_from_destination
from sap_cloud_sdk.core.odata._filter import FilterExpression
from sap_cloud_sdk.core.odata._models import ODataEntity
from sap_cloud_sdk.core.odata._pagination import ODataPageIterator
from sap_cloud_sdk.core.odata._query import OrderDirection, StructuredQuery
from sap_cloud_sdk.core.odata._request_builders import (
CreateRequestBuilder,
DeleteRequestBuilder,
GetAllRequestBuilder,
GetByKeyRequestBuilder,
UpdateRequestBuilder,
)
from sap_cloud_sdk.core.odata._transport import ODataHttpTransport
from sap_cloud_sdk.core.odata.exceptions import (
ODataAuthError,
ODataConnectionError,
ODataCsrfError,
ODataDeserializationError,
ODataError,
ODataNotFoundError,
ODataRequestError,
)

__all__ = [
"AsyncODataHttpTransport",
"CreateRequestBuilder",
"DeleteRequestBuilder",
"FilterExpression",
"GetAllRequestBuilder",
"GetByKeyRequestBuilder",
"ODataAuthError",
"ODataConnectionError",
"ODataCsrfError",
"ODataDeserializationError",
"ODataEntity",
"ODataError",
"ODataHttpTransport",
"ODataNotFoundError",
"ODataPageIterator",
"ODataRequestError",
"OrderDirection",
"StructuredQuery",
"UpdateRequestBuilder",
"odata_transport_from_destination",
]
199 changes: 199 additions & 0 deletions src/sap_cloud_sdk/core/odata/_async_transport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""Asynchronous HTTP transport for OData v4 services."""

from __future__ import annotations

import asyncio
import logging
from typing import Any

import httpx

from sap_cloud_sdk.core.odata._constants import (
CSRF_FETCH_TIMEOUT,
CSRF_FETCH_VALUE,
CSRF_HEADER,
DEFAULT_HEADERS,
MUTATING_METHODS,
REQUEST_TIMEOUT,
)
from sap_cloud_sdk.core.odata.exceptions import (
ODataAuthError,
ODataConnectionError,
ODataCsrfError,
ODataNotFoundError,
ODataRequestError,
)

logger = logging.getLogger(__name__)


class AsyncODataHttpTransport:
"""Asynchronous HTTP transport for OData v4 services.

Mirrors :class:`~sap_cloud_sdk.core.odata._transport.ODataHttpTransport`
but uses ``httpx.AsyncClient``. Use as an async context manager::

async with AsyncODataHttpTransport(base_url, client) as t:
data = await t.request("GET", "BusinessPartnerSet")

Args:
base_url: Root URL of the OData service.
client: Pre-configured ``httpx.AsyncClient``.
csrf_enabled: Whether to fetch and attach CSRF tokens on mutating
requests.
"""

def __init__(
self,
base_url: str,
client: httpx.AsyncClient,
csrf_enabled: bool = True,
) -> None:
self._base_url = base_url.rstrip("/")
self._client = client
self._csrf_enabled = csrf_enabled
self._csrf_token: str | None = None
self._csrf_lock = asyncio.Lock()

async def __aenter__(self) -> "AsyncODataHttpTransport":
return self

async def __aexit__(self, *args: Any) -> None:
await self._client.aclose()

async def request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json: Any | None = None,
headers: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Execute an OData request and return the parsed JSON body.

CSRF tokens are fetched and attached automatically for mutating methods
(POST, PUT, PATCH, DELETE) when ``csrf_enabled`` is ``True``. On a
403 response the cached token is invalidated and the request is retried
once with a fresh token.

Args:
method: HTTP method (``"GET"``, ``"POST"``, ``"PATCH"``, etc.).
path: Entity path relative to the service base URL.
params: OData query parameters.
json: Request body serialised as JSON.
headers: Extra headers merged on top of the defaults.

Returns:
Parsed JSON response body, or ``{}`` for 204 / empty responses.
"""
extra = dict(headers or {})

if method.upper() in MUTATING_METHODS and self._csrf_enabled:
extra[CSRF_HEADER] = await self._get_csrf_token()
try:
return await self._execute(
method, path, params=params, json=json, extra_headers=extra
)
except ODataAuthError as exc:
if exc.status_code == 403:
await self._invalidate_csrf_token()
extra[CSRF_HEADER] = await self._get_csrf_token()
return await self._execute(
method, path, params=params, json=json, extra_headers=extra
)
raise

return await self._execute(
method, path, params=params, json=json, extra_headers=extra
)

def absolute_url(self, path: str) -> str:
return self._base_url + "/" + path.lstrip("/")

# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------

async def _get_csrf_token(self) -> str:
async with self._csrf_lock:
if self._csrf_token is not None:
return self._csrf_token

token = await self._fetch_csrf_token()
async with self._csrf_lock:
if self._csrf_token is None:
self._csrf_token = token
return self._csrf_token # type: ignore[return-value]

async def _invalidate_csrf_token(self) -> None:
async with self._csrf_lock:
self._csrf_token = None

async def _fetch_csrf_token(self) -> str:
url = self._base_url + "/"
try:
resp = await self._client.get(
url,
headers={CSRF_HEADER: CSRF_FETCH_VALUE},
timeout=CSRF_FETCH_TIMEOUT,
)
except httpx.RequestError as exc:
raise ODataCsrfError(f"Async CSRF fetch failed: {exc}") from exc

token = resp.headers.get(CSRF_HEADER, "")
if not token:
raise ODataCsrfError(
f"Service did not return a CSRF token (HTTP {resp.status_code})"
)
return token

async def _execute(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json: Any | None = None,
extra_headers: dict[str, str] | None = None,
) -> dict[str, Any]:
url = self.absolute_url(path)
req_headers = {**DEFAULT_HEADERS, **(extra_headers or {})}

logger.debug("%s %s params=%s", method, url, params)
try:
resp = await self._client.request(
method=method,
url=url,
headers=req_headers,
params=params,
json=json,
timeout=REQUEST_TIMEOUT,
)
except httpx.RequestError as exc:
raise ODataConnectionError(f"Request failed: {exc}") from exc

self._raise_for_status(resp)

if resp.status_code == 204 or not resp.content:
return {}
return resp.json()

def _raise_for_status(self, response: httpx.Response) -> None:
if response.status_code == 404:
raise ODataNotFoundError(_HttpxResponseAdapter(response))
if response.status_code in (401, 403):
raise ODataAuthError(_HttpxResponseAdapter(response))
if not (200 <= response.status_code < 300):
raise ODataRequestError(_HttpxResponseAdapter(response))


class _HttpxResponseAdapter:
"""Minimal adapter so httpx.Response can be passed to ODataRequestError."""

def __init__(self, response: httpx.Response) -> None:
self.status_code = response.status_code
self._response = response

def json(self) -> Any:
return self._response.json()
52 changes: 52 additions & 0 deletions src/sap_cloud_sdk/core/odata/_constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Shared constants for the OData v4 HTTP layer."""

from __future__ import annotations

# ---------------------------------------------------------------------------
# CSRF
# ---------------------------------------------------------------------------

CSRF_HEADER = "X-CSRF-Token"
CSRF_FETCH_VALUE = "Fetch"
CSRF_FETCH_TIMEOUT = 10

# ---------------------------------------------------------------------------
# HTTP
# ---------------------------------------------------------------------------

REQUEST_TIMEOUT = 30

MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})

DEFAULT_HEADERS: dict[str, str] = {
"Accept": "application/json",
"Content-Type": "application/json",
}

# HTTP method literals
GET = "GET"
POST = "POST"
PUT = "PUT"
PATCH = "PATCH"
DELETE = "DELETE"

# Standard conditional-request header
IF_MATCH_HEADER = "If-Match"

# ---------------------------------------------------------------------------
# OData system query options
# ---------------------------------------------------------------------------

QUERY_SELECT = "$select"
QUERY_FILTER = "$filter"
QUERY_ORDERBY = "$orderby"
QUERY_TOP = "$top"
QUERY_SKIP = "$skip"
QUERY_EXPAND = "$expand"

# ---------------------------------------------------------------------------
# OData response envelope keys
# ---------------------------------------------------------------------------

RESPONSE_VALUE = "value"
RESPONSE_NEXT_LINK = "@odata.nextLink"
72 changes: 72 additions & 0 deletions src/sap_cloud_sdk/core/odata/_csrf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""CSRF token fetch-and-cache for OData v4 mutating requests."""

from __future__ import annotations

import threading
from typing import TYPE_CHECKING

import requests as _requests

from sap_cloud_sdk.core.odata._constants import (
CSRF_FETCH_TIMEOUT,
CSRF_FETCH_VALUE,
CSRF_HEADER,
)
from sap_cloud_sdk.core.odata.exceptions import ODataCsrfError

if TYPE_CHECKING:
from ._transport import ODataHttpTransport


class CsrfTokenProvider:
"""Fetch and cache a CSRF token for one OData service root.

The token is fetched lazily on the first mutating request and cached
until it is invalidated (typically after a 403 response).

Thread-safe: internal state is protected by a :class:`threading.Lock`.

Args:
transport: The owning :class:`ODataHttpTransport` whose session and
base URL are used to perform the CSRF-fetch GET.
"""

def __init__(self, transport: "ODataHttpTransport") -> None:
self._transport = transport
self._token: str | None = None
self._lock = threading.Lock()

def get(self) -> str:
"""Return the cached CSRF token, fetching from the service if needed."""
with self._lock:
if self._token is not None:
return self._token

token = self._fetch()
with self._lock:
if self._token is None:
self._token = token
return self._token

def invalidate(self) -> None:
"""Discard the cached token so the next call re-fetches."""
with self._lock:
self._token = None

def _fetch(self) -> str:
url = self._transport._base_url + "/"
try:
resp = self._transport._session.get(
url,
headers={CSRF_HEADER: CSRF_FETCH_VALUE},
timeout=CSRF_FETCH_TIMEOUT,
)
except _requests.RequestException as exc:
raise ODataCsrfError(f"CSRF fetch failed: {exc}") from exc

token = resp.headers.get(CSRF_HEADER, "")
if not token:
raise ODataCsrfError(
f"Service did not return a CSRF token (HTTP {resp.status_code})"
)
return token
Loading
Loading