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.29.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
21 changes: 21 additions & 0 deletions src/sap_cloud_sdk/core/http_client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""General-purpose HTTP client for the SAP Cloud SDK."""

from sap_cloud_sdk.core.http_client._client import HttpClient
from sap_cloud_sdk.core.http_client._factory import http_client_for_destination
from sap_cloud_sdk.core.http_client.exceptions import (
HttpClientError,
HttpConnectionError,
HttpNotFoundError,
HttpResponseError,
HttpUnauthorizedError,
)

__all__ = [
"HttpClient",
"HttpClientError",
"HttpConnectionError",
"HttpNotFoundError",
"HttpResponseError",
"HttpUnauthorizedError",
"http_client_for_destination",
]
268 changes: 268 additions & 0 deletions src/sap_cloud_sdk/core/http_client/_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
"""General-purpose HTTP client."""

from __future__ import annotations

import logging
from typing import Any

import requests
from requests.exceptions import RequestException

from sap_cloud_sdk.core.http_client.exceptions import (
HttpConnectionError,
HttpNotFoundError,
HttpResponseError,
HttpUnauthorizedError,
)
from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics

GET = "GET"
POST = "POST"
PUT = "PUT"
PATCH = "PATCH"
DELETE = "DELETE"

logger = logging.getLogger(__name__)


def _raise_for_status(response: requests.Response) -> None:
if response.status_code == 404:
raise HttpNotFoundError(response)
if response.status_code in (401, 403):
raise HttpUnauthorizedError(response)
if not response.ok:
raise HttpResponseError(response)


class HttpClient:
"""General-purpose HTTP client with typed convenience methods.

Wraps a pre-configured ``requests.Session`` and raises typed exceptions on
non-2xx responses.

Use :func:`~sap_cloud_sdk.core.http_client.http_client_for_destination`
to construct an instance from a resolved BTP ``Destination``. For advanced
use, inject any ``requests.Session`` directly.

Args:
base_url: Root URL of the target system.
session: Pre-configured ``requests.Session`` (auth pre-baked by caller).

Example::

from sap_cloud_sdk.destination import create_client
from sap_cloud_sdk.core.http_client import http_client_for_destination

dest = create_client().get_destination("MY_API")
client = http_client_for_destination(dest)
response = client.get("/api/v1/resources")
"""

def __init__(self, base_url: str, session: requests.Session) -> None:
self._base_url = base_url.rstrip("/")
self._session = session

def request(
self,
method: str,
path: str = "",
*,
params: dict[str, Any] | None = None,
json: Any | None = None,
data: Any | None = None,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> requests.Response:
"""Send an HTTP request and return the raw response.

Unlike the convenience methods (:meth:`get`, :meth:`post`, etc.) this
method does **not** raise on non-2xx status codes — the caller is
responsible for inspecting the response.

Args:
method: HTTP verb (``"GET"``, ``"POST"``, ``"PUT"``, etc.).
path: Path relative to the base URL. Empty string sends to the
base URL itself.
params: Query parameters.
json: Request body serialised as JSON.
data: Raw request body.
headers: Extra headers merged with the session defaults.
**kwargs: Forwarded to ``requests.Session.request``.

Returns:
The raw ``requests.Response`` object.

Raises:
HttpConnectionError: If a network-level error prevents the request
from reaching the server.
"""
url = self._base_url + "/" + path.lstrip("/") if path else self._base_url
logger.debug("%s %s params=%s", method.upper(), url, params)
try:
return self._session.request(
method=method.upper(),
url=url,
params=params,
json=json,
data=data,
headers=headers,
**kwargs,
)
except RequestException as exc:
raise HttpConnectionError(str(exc)) from exc

@record_metrics(Module.HTTP_CLIENT, Operation.HTTP_CLIENT_GET)
def get(
self,
path: str = "",
*,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> requests.Response:
"""Send a GET request.

Args:
path: Path relative to the base URL.
params: Query parameters.
headers: Extra headers.
**kwargs: Forwarded to ``requests.Session.request``.

Returns:
``requests.Response`` on success (2xx).

Raises:
HttpNotFoundError: On HTTP 404.
HttpUnauthorizedError: On HTTP 401 or 403.
HttpResponseError: On any other non-2xx status.
HttpConnectionError: On network failure.
"""
resp = self.request(GET, path, params=params, headers=headers, **kwargs)
_raise_for_status(resp)
return resp

@record_metrics(Module.HTTP_CLIENT, Operation.HTTP_CLIENT_POST)
def post(
self,
path: str = "",
*,
json: Any | None = None,
data: Any | None = None,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> requests.Response:
"""Send a POST request.

Args:
path: Path relative to the base URL.
json: Body serialised as JSON.
data: Raw body.
headers: Extra headers.
**kwargs: Forwarded to ``requests.Session.request``.

Returns:
``requests.Response`` on success (2xx).

Raises:
HttpNotFoundError: On HTTP 404.
HttpUnauthorizedError: On HTTP 401 or 403.
HttpResponseError: On any other non-2xx status.
HttpConnectionError: On network failure.
"""
resp = self.request(POST, path, json=json, data=data, headers=headers, **kwargs)
_raise_for_status(resp)
return resp

@record_metrics(Module.HTTP_CLIENT, Operation.HTTP_CLIENT_PUT)
def put(
self,
path: str = "",
*,
json: Any | None = None,
data: Any | None = None,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> requests.Response:
"""Send a PUT request.

Args:
path: Path relative to the base URL.
json: Body serialised as JSON.
data: Raw body.
headers: Extra headers.
**kwargs: Forwarded to ``requests.Session.request``.

Returns:
``requests.Response`` on success (2xx).

Raises:
HttpNotFoundError: On HTTP 404.
HttpUnauthorizedError: On HTTP 401 or 403.
HttpResponseError: On any other non-2xx status.
HttpConnectionError: On network failure.
"""
resp = self.request(PUT, path, json=json, data=data, headers=headers, **kwargs)
_raise_for_status(resp)
return resp

@record_metrics(Module.HTTP_CLIENT, Operation.HTTP_CLIENT_PATCH)
def patch(
self,
path: str = "",
*,
json: Any | None = None,
data: Any | None = None,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> requests.Response:
"""Send a PATCH request.

Args:
path: Path relative to the base URL.
json: Body serialised as JSON.
data: Raw body.
headers: Extra headers.
**kwargs: Forwarded to ``requests.Session.request``.

Returns:
``requests.Response`` on success (2xx).

Raises:
HttpNotFoundError: On HTTP 404.
HttpUnauthorizedError: On HTTP 401 or 403.
HttpResponseError: On any other non-2xx status.
HttpConnectionError: On network failure.
"""
resp = self.request(
PATCH, path, json=json, data=data, headers=headers, **kwargs
)
_raise_for_status(resp)
return resp

@record_metrics(Module.HTTP_CLIENT, Operation.HTTP_CLIENT_DELETE)
def delete(
self,
path: str = "",
*,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> requests.Response:
"""Send a DELETE request.

Args:
path: Path relative to the base URL.
headers: Extra headers.
**kwargs: Forwarded to ``requests.Session.request``.

Returns:
``requests.Response`` on success (2xx).

Raises:
HttpNotFoundError: On HTTP 404.
HttpUnauthorizedError: On HTTP 401 or 403.
HttpResponseError: On any other non-2xx status.
HttpConnectionError: On network failure.
"""
resp = self.request(DELETE, path, headers=headers, **kwargs)
_raise_for_status(resp)
return resp
69 changes: 69 additions & 0 deletions src/sap_cloud_sdk/core/http_client/_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Factory for building HttpClient from BTP Destinations."""

from __future__ import annotations

from typing import TYPE_CHECKING

import requests

from sap_cloud_sdk.core.http_client._client import HttpClient

if TYPE_CHECKING:
from sap_cloud_sdk.destination._models import Destination


def http_client_for_destination(
destination: "Destination",
*,
sub_path: str = "",
) -> HttpClient:
"""Build an :class:`HttpClient` from a resolved BTP Destination.

The destination's auth tokens and ERP headers are pre-baked into the
underlying ``requests.Session``, so no manual header management is needed.
Mirrors the pattern of
:func:`~sap_cloud_sdk.core.odata._factory.odata_transport_from_destination`.

Args:
destination: A fully-resolved ``Destination`` object (i.e. returned by
``DestinationClient.get_destination()`` so ``auth_tokens`` are
populated).
sub_path: Optional sub-path appended to the destination URL to form
the service root (e.g. ``"api/v1"``). Useful when the destination
URL points to the host root rather than the service root directly.

Returns:
:class:`HttpClient` ready to call the target system.

Raises:
ValueError: If the destination is not an HTTP destination or has no URL.

Example::

from sap_cloud_sdk.destination import create_client
from sap_cloud_sdk.core.http_client import http_client_for_destination

dest = create_client().get_destination("MY_API")
client = http_client_for_destination(dest, sub_path="api/v1")
response = client.get("/resources")
"""
from sap_cloud_sdk.destination._models import DestinationType

if destination.type != DestinationType.HTTP:
raise ValueError(
f"http_client_for_destination only supports HTTP destinations, "
f"got: {destination.type}"
)
if not destination.url:
raise ValueError(
f"Destination '{destination.name}' has no URL — cannot build HTTP client"
)

base_url = destination.url.rstrip("/")
if sub_path:
base_url = base_url + "/" + sub_path.strip("/")

session = requests.Session()
session.headers.update(destination.get_headers())

return HttpClient(base_url=base_url, session=session)
30 changes: 30 additions & 0 deletions src/sap_cloud_sdk/core/http_client/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""HTTP client exception hierarchy."""

from __future__ import annotations

from typing import Any


class HttpClientError(Exception):
"""Base for all HTTP client errors."""


class HttpResponseError(HttpClientError):
"""Non-2xx HTTP response received from the target system."""

def __init__(self, response: Any) -> None:
self.status_code: int = response.status_code
self.response = response
super().__init__(f"HTTP {response.status_code}: {response.url}")


class HttpNotFoundError(HttpResponseError):
"""Resource not found (HTTP 404)."""


class HttpUnauthorizedError(HttpResponseError):
"""Authentication or authorisation failure (HTTP 401/403)."""


class HttpConnectionError(HttpClientError):
"""Network-level failure — no HTTP response was received."""
Empty file.
Loading
Loading