Skip to content

Commit 37ad206

Browse files
authored
Merge pull request #1 from JagnathReddy/dms-integration
DMS integration
2 parents 02aee9c + 38c3194 commit 37ad206

13 files changed

Lines changed: 1133 additions & 0 deletions

File tree

src/sap_cloud_sdk/core/telemetry/module.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class Module(str, Enum):
1010
AUDITLOG = "auditlog"
1111
DESTINATION = "destination"
1212
OBJECTSTORE = "objectstore"
13+
DMS = "dms"
1314

1415
def __str__(self) -> str:
1516
return self.value

src/sap_cloud_sdk/core/telemetry/operation.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,17 @@ class Operation(str, Enum):
5252
AICORE_SET_CONFIG = "set_aicore_config"
5353
AICORE_AUTO_INSTRUMENT = "auto_instrument"
5454

55+
56+
# DMS Operations
57+
DMS_ONBOARD_REPOSITORY = "onboard_repository"
58+
DMS_GET_REPOSITORY = "get_repository"
59+
DMS_GET_ALL_REPOSITORIES = "get_all_repositories"
60+
DMS_UPDATE_REPOSITORY = "update_repository"
61+
DMS_DELETE_REPOSITORY = "delete_repository"
62+
DMS_CREATE_CONFIG = "create_config"
63+
DMS_GET_CONFIGS = "get_configs"
64+
DMS_UPDATE_CONFIG = "update_config"
65+
DMS_DELETE_CONFIG = "delete_config"
66+
5567
def __str__(self) -> str:
5668
return self.value

src/sap_cloud_sdk/dms/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from typing import Optional
2+
from sap_cloud_sdk.dms.model import DMSCredentials
3+
from sap_cloud_sdk.dms.client import DMSClient
4+
from sap_cloud_sdk.dms.config import load_sdm_config_from_env_or_mount
5+
6+
7+
def create_client(
8+
*,
9+
instance: Optional[str] = None,
10+
dms_cred: Optional[DMSCredentials] = None
11+
):
12+
if dms_cred is not None:
13+
return DMSClient(dms_cred)
14+
if instance is not None:
15+
return DMSClient(load_sdm_config_from_env_or_mount(instance))
16+
17+
raise ValueError("No configuration provided. Please provide either instance name, config, or dms_cred.")
18+
19+
__all__ = ["create_client"]

src/sap_cloud_sdk/dms/_auth.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import logging
2+
import time
3+
import requests
4+
from requests.exceptions import RequestException
5+
from typing import Optional, TypedDict
6+
from sap_cloud_sdk.dms.exceptions import DMSError, DMSConnectionError, DMSPermissionDeniedException
7+
from sap_cloud_sdk.dms.model import DMSCredentials
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
class _TokenResponse(TypedDict):
13+
access_token: str
14+
expires_in: int
15+
16+
17+
class _CachedToken:
18+
def __init__(self, token: str, expires_at: float) -> None:
19+
self.token = token
20+
self.expires_at = expires_at
21+
22+
def is_valid(self) -> bool:
23+
return time.monotonic() < self.expires_at - 30
24+
25+
26+
# TODO: limit number of access tokens in cache to 10
27+
class Auth:
28+
"""Fetches and caches OAuth2 access tokens for DMS service requests."""
29+
30+
def __init__(self, credentials: DMSCredentials) -> None:
31+
self._credentials = credentials
32+
self._cache: dict[str, _CachedToken] = {}
33+
34+
def get_token(self, tenant_subdomain: Optional[str] = None) -> str:
35+
cache_key = tenant_subdomain or "technical"
36+
37+
cached = self._cache.get(cache_key)
38+
if cached and cached.is_valid():
39+
logger.debug("Using cached token for key '%s'", cache_key)
40+
return cached.token
41+
42+
logger.debug("Fetching new token for key '%s'", cache_key)
43+
token_url = self._resolve_token_url(tenant_subdomain)
44+
token = self._fetch_token(token_url)
45+
46+
self._cache[cache_key] = _CachedToken(
47+
token=token["access_token"],
48+
expires_at=time.monotonic() + token.get("expires_in", 3600),
49+
)
50+
logger.debug("Token cached for key '%s'", cache_key)
51+
return self._cache[cache_key].token
52+
53+
def _resolve_token_url(self, tenant_subdomain: Optional[str]) -> str:
54+
if not tenant_subdomain:
55+
return self._credentials.token_url
56+
logger.debug("Resolving token URL for tenant '%s'", tenant_subdomain)
57+
return self._credentials.token_url.replace(
58+
self._credentials.identityzone,
59+
tenant_subdomain,
60+
)
61+
62+
def _fetch_token(self, token_url: str) -> _TokenResponse:
63+
try:
64+
response = requests.post(
65+
f"{token_url}/oauth/token",
66+
data={
67+
"grant_type": "client_credentials",
68+
"client_id": self._credentials.client_id,
69+
"client_secret": self._credentials.client_secret,
70+
},
71+
headers={"Content-Type": "application/x-www-form-urlencoded"},
72+
timeout=10,
73+
)
74+
response.raise_for_status()
75+
except requests.exceptions.ConnectionError as e:
76+
logger.error("Failed to connect to token endpoint")
77+
raise DMSConnectionError("Failed to connect to the authentication server") from e
78+
except requests.exceptions.HTTPError as e:
79+
status = e.response.status_code if e.response is not None else None
80+
logger.error("Token request failed with status %s", status)
81+
if status in (401, 403):
82+
raise DMSPermissionDeniedException("Authentication failed — invalid client credentials", status) from e
83+
raise DMSError("Failed to obtain access token", status) from e
84+
except RequestException as e:
85+
logger.error("Unexpected error during token fetch")
86+
raise DMSConnectionError("Unexpected error during authentication") from e
87+
88+
payload: _TokenResponse = response.json()
89+
if not payload.get("access_token"):
90+
raise DMSError("Token response missing access_token")
91+
92+
logger.debug("Token fetched successfully")
93+
return payload
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
REPOSITORIES = "/rest/v2/repositories"
2+
CONFIGS = "/rest/v2/configs"

src/sap_cloud_sdk/dms/_http.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import logging
2+
from typing import Any, Optional
3+
from requests import Response
4+
import requests
5+
from requests.exceptions import RequestException
6+
from sap_cloud_sdk.dms._auth import Auth
7+
from sap_cloud_sdk.dms.exceptions import (
8+
DMSError,
9+
DMSConnectionError,
10+
DMSInvalidArgumentException,
11+
DMSObjectNotFoundException,
12+
DMSPermissionDeniedException,
13+
DMSRuntimeException,
14+
)
15+
from sap_cloud_sdk.dms.model import UserClaim
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
class HttpInvoker:
21+
"""Low-level HTTP layer. Injects auth headers and enforces timeouts."""
22+
23+
def __init__(
24+
self,
25+
auth: Auth,
26+
base_url: str,
27+
connect_timeout: int | None = None,
28+
read_timeout: int | None = None,
29+
) -> None:
30+
self._auth = auth
31+
self._base_url = base_url.rstrip("/")
32+
self._connect_timeout = connect_timeout or 10
33+
self._read_timeout = read_timeout or 30
34+
35+
def get(
36+
self,
37+
path: str,
38+
tenant_subdomain: Optional[str] = None,
39+
headers: Optional[dict[str, str]] = None,
40+
user_claim: Optional[UserClaim] = None,
41+
) -> Response:
42+
logger.debug("GET %s", path)
43+
return self._handle(self._execute(
44+
lambda: requests.get(
45+
f"{self._base_url}{path}",
46+
headers=self._merged_headers(tenant_subdomain, headers, user_claim),
47+
timeout=(self._connect_timeout, self._read_timeout),
48+
)
49+
))
50+
51+
def post(
52+
self,
53+
path: str,
54+
payload: dict[str, Any],
55+
tenant_subdomain: Optional[str] = None,
56+
headers: Optional[dict[str, str]] = None,
57+
user_claim: Optional[UserClaim] = None,
58+
) -> Response:
59+
logger.debug("POST %s", path)
60+
return self._handle(self._execute(
61+
lambda: requests.post(
62+
f"{self._base_url}{path}",
63+
headers=self._merged_headers(tenant_subdomain, headers, user_claim),
64+
json=payload,
65+
timeout=(self._connect_timeout, self._read_timeout),
66+
)
67+
))
68+
69+
def put(
70+
self,
71+
path: str,
72+
payload: dict[str, Any],
73+
tenant_subdomain: Optional[str] = None,
74+
headers: Optional[dict[str, str]] = None,
75+
user_claim: Optional[UserClaim] = None,
76+
) -> Response:
77+
logger.debug("PUT %s", path)
78+
return self._handle(self._execute(
79+
lambda: requests.put(
80+
f"{self._base_url}{path}",
81+
headers=self._merged_headers(tenant_subdomain, headers, user_claim),
82+
json=payload,
83+
timeout=(self._connect_timeout, self._read_timeout),
84+
)
85+
))
86+
87+
def delete(
88+
self,
89+
path: str,
90+
tenant_subdomain: Optional[str] = None,
91+
headers: Optional[dict[str, str]] = None,
92+
user_claim: Optional[UserClaim] = None,
93+
) -> Response:
94+
logger.debug("DELETE %s", path)
95+
return self._handle(self._execute(
96+
lambda: requests.delete(
97+
f"{self._base_url}{path}",
98+
headers=self._merged_headers(tenant_subdomain, headers, user_claim),
99+
timeout=(self._connect_timeout, self._read_timeout),
100+
)
101+
))
102+
103+
def _execute(self, fn: Any) -> Response:
104+
"""Execute an HTTP call, wrapping network errors into DMSConnectionError."""
105+
try:
106+
return fn()
107+
except requests.exceptions.ConnectionError as e:
108+
logger.error("Connection error during HTTP request")
109+
raise DMSConnectionError("Failed to connect to the DMS service") from e
110+
except requests.exceptions.Timeout as e:
111+
logger.error("Request timed out")
112+
raise DMSConnectionError("Request to DMS service timed out") from e
113+
except RequestException as e:
114+
logger.error("Unexpected network error")
115+
raise DMSConnectionError("Unexpected network error") from e
116+
117+
def _default_headers(self, tenant_subdomain: Optional[str] = None) -> dict[str, str]:
118+
return {
119+
"Authorization": f"Bearer {self._auth.get_token(tenant_subdomain)}",
120+
"Content-Type": "application/json",
121+
"Accept": "application/json",
122+
}
123+
124+
def _user_claim_headers(self, user_claim: Optional[UserClaim]) -> dict[str, str]:
125+
if not user_claim:
126+
return {}
127+
headers: dict[str, str] = {}
128+
if user_claim.x_ecm_user_enc:
129+
headers["X-EcmUserEnc"] = user_claim.x_ecm_user_enc
130+
if user_claim.x_ecm_add_principals:
131+
headers["X-EcmAddPrincipals"] = ";".join(user_claim.x_ecm_add_principals)
132+
return headers
133+
134+
def _merged_headers(
135+
self,
136+
tenant_subdomain: Optional[str],
137+
overrides: Optional[dict[str, str]],
138+
user_claim: Optional[UserClaim] = None,
139+
) -> dict[str, str]:
140+
return {
141+
**self._default_headers(tenant_subdomain),
142+
**self._user_claim_headers(user_claim),
143+
**(overrides or {}),
144+
}
145+
146+
def _handle(self, response: Response) -> Response:
147+
logger.debug("Response status: %s", response.status_code)
148+
if response.status_code in (200, 201, 204):
149+
return response
150+
151+
# error_content kept for debugging but not surfaced in the exception message
152+
error_content = response.text
153+
logger.warning("Request failed with status %s", response.status_code)
154+
155+
match response.status_code:
156+
case 400:
157+
raise DMSInvalidArgumentException(
158+
"Request contains invalid or disallowed parameters", 400, error_content
159+
)
160+
case 401 | 403:
161+
raise DMSPermissionDeniedException(
162+
"Access denied — invalid or expired token", response.status_code, error_content
163+
)
164+
case 404:
165+
raise DMSObjectNotFoundException(
166+
"The requested resource was not found", 404, error_content
167+
)
168+
case 500:
169+
raise DMSRuntimeException(
170+
"The DMS service encountered an internal error", 500, error_content
171+
)
172+
case _:
173+
raise DMSError(
174+
f"Unexpected response from DMS service : "+error_content, response.status_code, error_content
175+
)

0 commit comments

Comments
 (0)