Skip to content

Commit 7de09bb

Browse files
authored
Merge pull request #2 from I559656/cmis-retrieval-apis
Add CMIS retrieval and user APIs for DMS module
2 parents 37ad206 + 1a009b5 commit 7de09bb

21 files changed

Lines changed: 4841 additions & 103 deletions

src/sap_cloud_sdk/core/telemetry/operation.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ class Operation(str, Enum):
5252
AICORE_SET_CONFIG = "set_aicore_config"
5353
AICORE_AUTO_INSTRUMENT = "auto_instrument"
5454

55-
5655
# DMS Operations
5756
DMS_ONBOARD_REPOSITORY = "onboard_repository"
5857
DMS_GET_REPOSITORY = "get_repository"
@@ -64,5 +63,17 @@ class Operation(str, Enum):
6463
DMS_UPDATE_CONFIG = "update_config"
6564
DMS_DELETE_CONFIG = "delete_config"
6665

66+
# DMS CMIS Operations
67+
DMS_CREATE_FOLDER = "create_folder"
68+
DMS_CREATE_DOCUMENT = "create_document"
69+
DMS_CHECK_OUT = "check_out"
70+
DMS_CHECK_IN = "check_in"
71+
DMS_CANCEL_CHECK_OUT = "cancel_check_out"
72+
DMS_APPLY_ACL = "apply_acl"
73+
DMS_GET_OBJECT = "get_object"
74+
DMS_GET_CONTENT = "get_content"
75+
DMS_UPDATE_PROPERTIES = "update_properties"
76+
DMS_GET_CHILDREN = "get_children"
77+
6778
def __str__(self) -> str:
6879
return self.value

src/sap_cloud_sdk/dms/__init__.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@
55

66

77
def create_client(
8-
*,
9-
instance: Optional[str] = None,
10-
dms_cred: Optional[DMSCredentials] = None
8+
*, instance: Optional[str] = None, dms_cred: Optional[DMSCredentials] = None
119
):
1210
if dms_cred is not None:
1311
return DMSClient(dms_cred)
1412
if instance is not None:
1513
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.")
1814

19-
__all__ = ["create_client"]
15+
raise ValueError(
16+
"No configuration provided. Please provide either instance name, config, or dms_cred."
17+
)
18+
19+
20+
__all__ = ["create_client"]

src/sap_cloud_sdk/dms/_auth.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import requests
44
from requests.exceptions import RequestException
55
from typing import Optional, TypedDict
6-
from sap_cloud_sdk.dms.exceptions import DMSError, DMSConnectionError, DMSPermissionDeniedException
6+
from sap_cloud_sdk.dms.exceptions import (
7+
DMSError,
8+
DMSConnectionError,
9+
DMSPermissionDeniedException,
10+
)
711
from sap_cloud_sdk.dms.model import DMSCredentials
812

913
logger = logging.getLogger(__name__)
@@ -14,6 +18,7 @@ class _TokenResponse(TypedDict):
1418
expires_in: int
1519

1620

21+
# TODO: limit number of access tokens in cache to 10
1722
class _CachedToken:
1823
def __init__(self, token: str, expires_at: float) -> None:
1924
self.token = token
@@ -74,12 +79,16 @@ def _fetch_token(self, token_url: str) -> _TokenResponse:
7479
response.raise_for_status()
7580
except requests.exceptions.ConnectionError as e:
7681
logger.error("Failed to connect to token endpoint")
77-
raise DMSConnectionError("Failed to connect to the authentication server") from e
82+
raise DMSConnectionError(
83+
"Failed to connect to the authentication server"
84+
) from e
7885
except requests.exceptions.HTTPError as e:
7986
status = e.response.status_code if e.response is not None else None
8087
logger.error("Token request failed with status %s", status)
8188
if status in (401, 403):
82-
raise DMSPermissionDeniedException("Authentication failed — invalid client credentials", status) from e
89+
raise DMSPermissionDeniedException(
90+
"Authentication failed — invalid client credentials", status
91+
) from e
8392
raise DMSError("Failed to obtain access token", status) from e
8493
except RequestException as e:
8594
logger.error("Unexpected error during token fetch")
@@ -90,4 +99,4 @@ def _fetch_token(self, token_url: str) -> _TokenResponse:
9099
raise DMSError("Token response missing access_token")
91100

92101
logger.debug("Token fetched successfully")
93-
return payload
102+
return payload
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
REPOSITORIES = "/rest/v2/repositories"
2-
CONFIGS = "/rest/v2/configs"
1+
REPOSITORIES = "/rest/v2/repositories"
2+
CONFIGS = "/rest/v2/configs"

src/sap_cloud_sdk/dms/_http.py

Lines changed: 135 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from sap_cloud_sdk.dms._auth import Auth
77
from sap_cloud_sdk.dms.exceptions import (
88
DMSError,
9+
DMSConflictException,
910
DMSConnectionError,
1011
DMSInvalidArgumentException,
1112
DMSObjectNotFoundException,
@@ -38,15 +39,19 @@ def get(
3839
tenant_subdomain: Optional[str] = None,
3940
headers: Optional[dict[str, str]] = None,
4041
user_claim: Optional[UserClaim] = None,
42+
params: Optional[dict[str, str]] = None,
4143
) -> Response:
4244
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),
45+
return self._handle(
46+
self._execute(
47+
lambda: requests.get(
48+
f"{self._base_url}{path}",
49+
headers=self._merged_headers(tenant_subdomain, headers, user_claim),
50+
params=params,
51+
timeout=(self._connect_timeout, self._read_timeout),
52+
)
4853
)
49-
))
54+
)
5055

5156
def post(
5257
self,
@@ -57,14 +62,16 @@ def post(
5762
user_claim: Optional[UserClaim] = None,
5863
) -> Response:
5964
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),
65+
return self._handle(
66+
self._execute(
67+
lambda: requests.post(
68+
f"{self._base_url}{path}",
69+
headers=self._merged_headers(tenant_subdomain, headers, user_claim),
70+
json=payload,
71+
timeout=(self._connect_timeout, self._read_timeout),
72+
)
6673
)
67-
))
74+
)
6875

6976
def put(
7077
self,
@@ -75,14 +82,16 @@ def put(
7582
user_claim: Optional[UserClaim] = None,
7683
) -> Response:
7784
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),
85+
return self._handle(
86+
self._execute(
87+
lambda: requests.put(
88+
f"{self._base_url}{path}",
89+
headers=self._merged_headers(tenant_subdomain, headers, user_claim),
90+
json=payload,
91+
timeout=(self._connect_timeout, self._read_timeout),
92+
)
8493
)
85-
))
94+
)
8695

8796
def delete(
8897
self,
@@ -92,13 +101,68 @@ def delete(
92101
user_claim: Optional[UserClaim] = None,
93102
) -> Response:
94103
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),
104+
return self._handle(
105+
self._execute(
106+
lambda: requests.delete(
107+
f"{self._base_url}{path}",
108+
headers=self._merged_headers(tenant_subdomain, headers, user_claim),
109+
timeout=(self._connect_timeout, self._read_timeout),
110+
)
111+
)
112+
)
113+
114+
def post_form(
115+
self,
116+
path: str,
117+
*,
118+
data: dict[str, str],
119+
files: Optional[dict[str, Any]] = None,
120+
tenant_subdomain: Optional[str] = None,
121+
user_claim: Optional[UserClaim] = None,
122+
) -> Response:
123+
"""POST with form-encoded data and optional multipart file uploads.
124+
125+
Does not set Content-Type — ``requests`` sets it automatically
126+
to ``application/x-www-form-urlencoded`` or ``multipart/form-data``.
127+
"""
128+
logger.debug("POST_FORM %s", path)
129+
return self._handle(
130+
self._execute(
131+
lambda: requests.post(
132+
f"{self._base_url}{path}",
133+
headers=self._auth_header(tenant_subdomain, user_claim),
134+
data=data,
135+
files=files,
136+
timeout=(self._connect_timeout, self._read_timeout),
137+
)
138+
)
139+
)
140+
141+
def get_stream(
142+
self,
143+
path: str,
144+
*,
145+
params: Optional[dict[str, str]] = None,
146+
tenant_subdomain: Optional[str] = None,
147+
user_claim: Optional[UserClaim] = None,
148+
) -> Response:
149+
"""GET that returns a raw streaming Response for binary content.
150+
151+
The caller is responsible for closing the response.
152+
On non-2xx status the usual typed exception is raised.
153+
"""
154+
logger.debug("GET_STREAM %s", path)
155+
return self._handle(
156+
self._execute(
157+
lambda: requests.get(
158+
f"{self._base_url}{path}",
159+
headers=self._merged_headers(tenant_subdomain, None, user_claim),
160+
params=params,
161+
stream=True,
162+
timeout=(self._connect_timeout, self._read_timeout),
163+
)
100164
)
101-
))
165+
)
102166

103167
def _execute(self, fn: Any) -> Response:
104168
"""Execute an HTTP call, wrapping network errors into DMSConnectionError."""
@@ -114,7 +178,20 @@ def _execute(self, fn: Any) -> Response:
114178
logger.error("Unexpected network error")
115179
raise DMSConnectionError("Unexpected network error") from e
116180

117-
def _default_headers(self, tenant_subdomain: Optional[str] = None) -> dict[str, str]:
181+
def _auth_header(
182+
self,
183+
tenant_subdomain: Optional[str] = None,
184+
user_claim: Optional[UserClaim] = None,
185+
) -> dict[str, str]:
186+
"""Auth-only headers (no Content-Type). Used by post_form."""
187+
return {
188+
"Authorization": f"Bearer {self._auth.get_token(tenant_subdomain)}",
189+
**self._user_claim_headers(user_claim),
190+
}
191+
192+
def _default_headers(
193+
self, tenant_subdomain: Optional[str] = None
194+
) -> dict[str, str]:
118195
return {
119196
"Authorization": f"Bearer {self._auth.get_token(tenant_subdomain)}",
120197
"Content-Type": "application/json",
@@ -152,24 +229,49 @@ def _handle(self, response: Response) -> Response:
152229
error_content = response.text
153230
logger.warning("Request failed with status %s", response.status_code)
154231

232+
# Try to extract the server's error message from the JSON body
233+
try:
234+
body = response.json()
235+
server_message = body.get("message", "") if isinstance(body, dict) else ""
236+
except Exception:
237+
server_message = ""
238+
155239
match response.status_code:
156240
case 400:
157241
raise DMSInvalidArgumentException(
158-
"Request contains invalid or disallowed parameters", 400, error_content
242+
server_message
243+
or "Request contains invalid or disallowed parameters",
244+
400,
245+
error_content,
159246
)
160247
case 401 | 403:
161248
raise DMSPermissionDeniedException(
162-
"Access denied — invalid or expired token", response.status_code, error_content
249+
server_message or "Access denied — invalid or expired token",
250+
response.status_code,
251+
error_content,
163252
)
164253
case 404:
165254
raise DMSObjectNotFoundException(
166-
"The requested resource was not found", 404, error_content
255+
server_message or "The requested resource was not found",
256+
404,
257+
error_content,
258+
)
259+
case 409:
260+
raise DMSConflictException(
261+
server_message
262+
or "The request conflicts with the current state of the resource",
263+
409,
264+
error_content,
167265
)
168266
case 500:
169267
raise DMSRuntimeException(
170-
"The DMS service encountered an internal error", 500, error_content
268+
server_message or "The DMS service encountered an internal error",
269+
500,
270+
error_content,
171271
)
172272
case _:
173273
raise DMSError(
174-
f"Unexpected response from DMS service : "+error_content, response.status_code, error_content
175-
)
274+
f"Unexpected response from DMS service: {error_content}",
275+
response.status_code,
276+
error_content,
277+
)

0 commit comments

Comments
 (0)