Skip to content

Commit 7b95592

Browse files
committed
feat: update http destination using core
1 parent 10c1831 commit 7b95592

9 files changed

Lines changed: 236 additions & 214 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# HTTP Client User Guide
2+
3+
This module provides a general-purpose HTTP client for calling target systems described by BTP Destinations. It handles auth-header injection, typed exceptions, and telemetry automatically.
4+
5+
```python
6+
from sap_cloud_sdk.core.http_client import (
7+
HttpClient,
8+
http_client_for_destination,
9+
HttpClientError,
10+
HttpConnectionError,
11+
HttpNotFoundError,
12+
HttpResponseError,
13+
HttpUnauthorizedError,
14+
)
15+
```
16+
17+
## Destination integration
18+
19+
`http_client_for_destination` builds an `HttpClient` from a resolved BTP `Destination`. The destination's auth tokens, ERP headers, and `URL.headers.*` properties are pre-baked into the underlying `requests.Session`, so no manual header management is needed.
20+
21+
```python
22+
from sap_cloud_sdk.destination import create_client
23+
from sap_cloud_sdk.core.http_client import http_client_for_destination
24+
25+
dest_client = create_client()
26+
dest = dest_client.get_destination("MY_API")
27+
28+
http = http_client_for_destination(dest)
29+
response = http.get("/api/v1/resources")
30+
data = response.json()
31+
```
32+
33+
> **Note:** Use a destination fetched via `get_destination()` (v2 API), which populates `auth_tokens`. Destinations from the deprecated v1 methods do not carry pre-fetched tokens.
34+
35+
When the destination URL points to a host root rather than the service root, pass `sub_path`:
36+
37+
```python
38+
http = http_client_for_destination(dest, sub_path="api/v1")
39+
response = http.get("/resources") # calls https://host/api/v1/resources
40+
```
41+
42+
## Direct construction
43+
44+
Inject any `requests.Session` directly when not using BTP Destinations:
45+
46+
```python
47+
import requests
48+
from sap_cloud_sdk.core.http_client import HttpClient
49+
50+
session = requests.Session()
51+
session.headers["Authorization"] = "Bearer <token>"
52+
53+
http = HttpClient(base_url="https://api.example.com", session=session)
54+
```
55+
56+
## HTTP methods
57+
58+
All convenience methods raise typed exceptions on non-2xx responses and record telemetry automatically.
59+
60+
```python
61+
# GET — with optional query parameters
62+
response = http.get("/items", params={"$top": "10", "status": "active"})
63+
64+
# POST — JSON body
65+
response = http.post("/items", json={"name": "new item", "type": "A"})
66+
67+
# PUT — full replacement
68+
response = http.put("/items/1", json={"name": "updated item"})
69+
70+
# PATCH — partial update
71+
response = http.patch("/items/1", json={"status": "inactive"})
72+
73+
# DELETE
74+
response = http.delete("/items/1")
75+
76+
# Low-level request — does NOT raise on non-2xx; caller inspects the response
77+
response = http.request("GET", "/items")
78+
if not response.ok:
79+
...
80+
```
81+
82+
### Extra headers per request
83+
84+
Pass `headers=` to add or override headers for a single call:
85+
86+
```python
87+
response = http.get("/items", headers={"X-Correlation-ID": "abc123"})
88+
```
89+
90+
Per-request headers are merged on top of the session defaults.
91+
92+
### Raw body
93+
94+
Use `data=` for non-JSON payloads:
95+
96+
```python
97+
response = http.post("/upload", data=b"raw bytes", headers={"Content-Type": "application/octet-stream"})
98+
```
99+
100+
## What headers are pre-baked
101+
102+
When `http_client_for_destination` builds the client it calls `dest.get_headers()`, which injects the following into the session:
103+
104+
1. **ERP headers**`sap-client` and `sap-language` from destination properties (if present)
105+
2. **`URL.headers.*` properties** — any destination property prefixed with `URL.headers.` becomes a header (e.g. `URL.headers.apiKey = secret``apiKey: secret`)
106+
3. **Auth tokens** — pre-fetched by BTP and returned in `dest.auth_tokens`; each token's `http_header` dict is injected directly (e.g. `Authorization: Bearer eyJ...`)
107+
108+
Auth tokens take precedence over `URL.headers.*` when both set the same key.
109+
110+
## Error handling
111+
112+
```python
113+
from sap_cloud_sdk.core.http_client import (
114+
http_client_for_destination,
115+
HttpNotFoundError,
116+
HttpUnauthorizedError,
117+
HttpResponseError,
118+
HttpConnectionError,
119+
)
120+
121+
http = http_client_for_destination(dest)
122+
123+
try:
124+
response = http.get("/items/123")
125+
except HttpNotFoundError:
126+
print("item not found")
127+
except HttpUnauthorizedError as e:
128+
print(f"auth failure (HTTP {e.status_code}): check destination configuration")
129+
except HttpResponseError as e:
130+
print(f"service error (HTTP {e.status_code})")
131+
except HttpConnectionError:
132+
print("network unreachable — no response received")
133+
```
134+
135+
Exception hierarchy:
136+
137+
```
138+
HttpClientError
139+
├── HttpResponseError # non-2xx HTTP response (carries .status_code and .response)
140+
│ ├── HttpNotFoundError # 404
141+
│ └── HttpUnauthorizedError # 401 / 403
142+
└── HttpConnectionError # network failure — no HTTP response received
143+
```
144+
145+
`HttpResponseError` exposes:
146+
- `.status_code: int` — the HTTP status code
147+
- `.response` — the raw `requests.Response` object

src/sap_cloud_sdk/core/odata/_factory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def odata_transport_from_destination(
2121
"""Build an :class:`ODataHttpTransport` from a resolved BTP Destination.
2222
2323
The destination's auth tokens and ERP headers are pre-baked into the
24-
underlying ``requests.Session`` exactly as ``DestinationHttpClient`` does,
24+
underlying ``requests.Session`` exactly as ``http_client_for_destination`` does,
2525
so the transport inherits whatever authentication the destination carries
2626
(Bearer, Basic, mTLS, …).
2727

src/sap_cloud_sdk/destination/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
)
4949
from sap_cloud_sdk.destination.config import load_from_env_or_mount, DestinationConfig
5050
from sap_cloud_sdk.destination._http import TokenProvider, DestinationHttp
51-
from sap_cloud_sdk.destination._destination_http_client import DestinationHttpClient
51+
from sap_cloud_sdk.core.http_client import HttpClient, http_client_for_destination
5252
from sap_cloud_sdk.destination.client import DestinationClient
5353
from sap_cloud_sdk.destination.fragment_client import FragmentClient
5454
from sap_cloud_sdk.destination.certificate_client import CertificateClient
@@ -236,7 +236,8 @@ def create_certificate_client(
236236
"LocalDevDestinationClient",
237237
"LocalDevFragmentClient",
238238
"LocalDevCertificateClient",
239-
"DestinationHttpClient",
239+
"HttpClient",
240+
"http_client_for_destination",
240241
# Exceptions
241242
"DestinationError",
242243
"ClientCreationError",

src/sap_cloud_sdk/destination/_destination_http_client.py

Lines changed: 0 additions & 67 deletions
This file was deleted.

src/sap_cloud_sdk/destination/_http.py

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from __future__ import annotations
99

1010
from typing import Any, Dict, Optional
11-
from enum import Enum
1211

1312
import requests
1413
from requests import Response
@@ -24,16 +23,6 @@
2423
API_V2 = "v2"
2524

2625

27-
class HttpMethod(Enum):
28-
"""HTTP method enumeration for request verb selection."""
29-
30-
GET = "GET"
31-
POST = "POST"
32-
PUT = "PUT"
33-
PATCH = "PATCH"
34-
DELETE = "DELETE"
35-
36-
3726
class TokenProvider:
3827
"""Provides OAuth2 access tokens with in-memory caching and proactive refresh."""
3928

@@ -117,7 +106,7 @@ def _auth_headers(self, tenant_subdomain: Optional[str] = None) -> Dict[str, str
117106

118107
def _request(
119108
self,
120-
method: HttpMethod | str,
109+
method: str,
121110
path: str,
122111
*,
123112
params: Optional[Dict[str, Any]] = None,
@@ -131,14 +120,9 @@ def _request(
131120
if extra_headers:
132121
headers.update(extra_headers)
133122

134-
# Normalize method to string
135-
method_str = (
136-
method.value if isinstance(method, HttpMethod) else str(method).upper()
137-
)
138-
139123
try:
140124
resp = self._session.request(
141-
method=method_str,
125+
method=method.upper(),
142126
url=url,
143127
headers=headers,
144128
params=params,
@@ -157,7 +141,7 @@ def _request(
157141
text = "<failed to read response body>"
158142

159143
raise HttpError(
160-
f"HTTP {resp.status_code} for {method_str} {url}",
144+
f"HTTP {resp.status_code} for {method.upper()} {url}",
161145
status_code=resp.status_code,
162146
response_text=text,
163147
)
@@ -187,7 +171,7 @@ def get(
187171
HttpError: If the request fails or returns a non-2xx status.
188172
"""
189173
return self._request(
190-
HttpMethod.GET,
174+
"GET",
191175
path,
192176
params=params,
193177
extra_headers=headers,
@@ -217,7 +201,7 @@ def post(
217201
HttpError: If the request fails or returns a non-2xx status.
218202
"""
219203
return self._request(
220-
HttpMethod.POST,
204+
"POST",
221205
path,
222206
json=body,
223207
extra_headers=headers,
@@ -247,7 +231,7 @@ def put(
247231
HttpError: If the request fails or returns a non-2xx status.
248232
"""
249233
return self._request(
250-
HttpMethod.PUT,
234+
"PUT",
251235
path,
252236
json=body,
253237
extra_headers=headers,
@@ -277,7 +261,7 @@ def patch(
277261
HttpError: If the request fails or returns a non-2xx status.
278262
"""
279263
return self._request(
280-
HttpMethod.PATCH,
264+
"PATCH",
281265
path,
282266
json=body,
283267
extra_headers=headers,
@@ -305,7 +289,7 @@ def delete(
305289
HttpError: If the request fails or returns a non-2xx status.
306290
"""
307291
return self._request(
308-
HttpMethod.DELETE,
292+
"DELETE",
309293
path,
310294
extra_headers=headers,
311295
tenant_subdomain=tenant_subdomain,

0 commit comments

Comments
 (0)