|
18 | 18 | from contextlib import contextmanager |
19 | 19 | from contextvars import ContextVar |
20 | 20 |
|
21 | | -from ..core._http import _HttpClient |
| 21 | +from ..core._http import _HttpClient, _HttpTiming |
22 | 22 | from ._upload import _ODataFileUpload |
23 | 23 | from ..core.errors import * |
24 | 24 | from ..core._error_codes import ( |
|
33 | 33 | METADATA_COLUMN_NOT_FOUND, |
34 | 34 | VALIDATION_UNSUPPORTED_CACHE_KIND, |
35 | 35 | ) |
| 36 | +from ..core.results import RequestMetadata |
36 | 37 |
|
37 | 38 | from ..__version__ import __version__ as _SDK_VERSION |
38 | 39 |
|
@@ -251,6 +252,279 @@ def _request(self, method: str, url: str, *, expected: tuple[int, ...] = _DEFAUL |
251 | 252 | is_transient=is_transient, |
252 | 253 | ) |
253 | 254 |
|
| 255 | + def _request_with_metadata( |
| 256 | + self, method: str, url: str, *, expected: tuple[int, ...] = _DEFAULT_EXPECTED_STATUSES, **kwargs |
| 257 | + ) -> tuple[Any, RequestMetadata]: |
| 258 | + """Execute HTTP request and return response with RequestMetadata. |
| 259 | +
|
| 260 | + This method is used internally to capture telemetry for the fluent |
| 261 | + ``.with_detail_response()`` API pattern. |
| 262 | +
|
| 263 | + :param method: HTTP method (GET, POST, PUT, DELETE, etc.). |
| 264 | + :type method: ``str`` |
| 265 | + :param url: Target URL for the request. |
| 266 | + :type url: ``str`` |
| 267 | + :param expected: Tuple of acceptable HTTP status codes. |
| 268 | + :type expected: ``tuple[int, ...]`` |
| 269 | + :param kwargs: Additional arguments passed to the underlying HTTP request. |
| 270 | + :return: Tuple of (response, RequestMetadata). |
| 271 | + :rtype: ``tuple[Any, RequestMetadata]`` |
| 272 | + :raises HttpError: If the response status is not in expected statuses. |
| 273 | + """ |
| 274 | + request_context = _RequestContext.build( |
| 275 | + method, |
| 276 | + url, |
| 277 | + expected=expected, |
| 278 | + merge_headers=self._merge_headers, |
| 279 | + **kwargs, |
| 280 | + ) |
| 281 | + |
| 282 | + # Use timing-aware request |
| 283 | + r, timing = self._http._request_with_timing( |
| 284 | + request_context.method, request_context.url, **request_context.kwargs |
| 285 | + ) |
| 286 | + |
| 287 | + # Extract service request ID from response headers |
| 288 | + response_headers = getattr(r, "headers", {}) or {} |
| 289 | + service_request_id = ( |
| 290 | + response_headers.get("x-ms-service-request-id") |
| 291 | + or response_headers.get("req_id") |
| 292 | + or response_headers.get("x-ms-request-id") |
| 293 | + ) |
| 294 | + |
| 295 | + # Build metadata |
| 296 | + metadata = RequestMetadata( |
| 297 | + client_request_id=request_context.headers.get("x-ms-client-request-id"), |
| 298 | + correlation_id=request_context.headers.get("x-ms-correlation-id"), |
| 299 | + service_request_id=service_request_id, |
| 300 | + http_status_code=r.status_code, |
| 301 | + timing_ms=timing.elapsed_ms, |
| 302 | + ) |
| 303 | + |
| 304 | + if r.status_code in request_context.expected: |
| 305 | + return r, metadata |
| 306 | + |
| 307 | + # Error handling - same logic as _request but we have metadata available |
| 308 | + body_excerpt = (getattr(r, "text", "") or "")[:200] |
| 309 | + svc_code = None |
| 310 | + msg = f"HTTP {r.status_code}" |
| 311 | + try: |
| 312 | + data = r.json() if getattr(r, "text", None) else {} |
| 313 | + if isinstance(data, dict): |
| 314 | + inner = data.get("error") |
| 315 | + if isinstance(inner, dict): |
| 316 | + svc_code = inner.get("code") |
| 317 | + imsg = inner.get("message") |
| 318 | + if isinstance(imsg, str) and imsg.strip(): |
| 319 | + msg = imsg.strip() |
| 320 | + else: |
| 321 | + imsg2 = data.get("message") |
| 322 | + if isinstance(imsg2, str) and imsg2.strip(): |
| 323 | + msg = imsg2.strip() |
| 324 | + except Exception: |
| 325 | + pass |
| 326 | + sc = r.status_code |
| 327 | + subcode = _http_subcode(sc) |
| 328 | + traceparent = response_headers.get("traceparent") |
| 329 | + ra = response_headers.get("Retry-After") |
| 330 | + retry_after = None |
| 331 | + if ra: |
| 332 | + try: |
| 333 | + retry_after = int(ra) |
| 334 | + except Exception: |
| 335 | + retry_after = None |
| 336 | + is_transient = _is_transient_status(sc) |
| 337 | + raise HttpError( |
| 338 | + msg, |
| 339 | + status_code=sc, |
| 340 | + subcode=subcode, |
| 341 | + service_error_code=svc_code, |
| 342 | + correlation_id=metadata.correlation_id, |
| 343 | + client_request_id=metadata.client_request_id, |
| 344 | + service_request_id=metadata.service_request_id, |
| 345 | + traceparent=traceparent, |
| 346 | + body_excerpt=body_excerpt, |
| 347 | + retry_after=retry_after, |
| 348 | + is_transient=is_transient, |
| 349 | + ) |
| 350 | + |
| 351 | + # --- CRUD Internal functions with metadata --- |
| 352 | + def _create_with_metadata( |
| 353 | + self, entity_set: str, table_schema_name: str, record: Dict[str, Any] |
| 354 | + ) -> tuple[str, RequestMetadata]: |
| 355 | + """Create a single record and return its GUID with metadata. |
| 356 | +
|
| 357 | + Same as :meth:`_create` but returns a tuple of (record_id, RequestMetadata) |
| 358 | + for the fluent API pattern. |
| 359 | +
|
| 360 | + :param entity_set: Resolved entity set (plural) name. |
| 361 | + :type entity_set: ``str`` |
| 362 | + :param table_schema_name: Schema name of the table. |
| 363 | + :type table_schema_name: ``str`` |
| 364 | + :param record: Attribute payload mapped by logical column names. |
| 365 | + :type record: ``dict[str, Any]`` |
| 366 | + :return: Tuple of (created record GUID, request metadata). |
| 367 | + :rtype: ``tuple[str, RequestMetadata]`` |
| 368 | + """ |
| 369 | + record = self._lowercase_keys(record) |
| 370 | + record = self._convert_labels_to_ints(table_schema_name, record) |
| 371 | + url = f"{self.api}/{entity_set}" |
| 372 | + r, metadata = self._request_with_metadata("post", url, json=record) |
| 373 | + |
| 374 | + ent_loc = r.headers.get("OData-EntityId") or r.headers.get("OData-EntityID") |
| 375 | + if ent_loc: |
| 376 | + m = _GUID_RE.search(ent_loc) |
| 377 | + if m: |
| 378 | + return m.group(0), metadata |
| 379 | + loc = r.headers.get("Location") |
| 380 | + if loc: |
| 381 | + m = _GUID_RE.search(loc) |
| 382 | + if m: |
| 383 | + return m.group(0), metadata |
| 384 | + header_keys = ", ".join(sorted(r.headers.keys())) |
| 385 | + raise RuntimeError( |
| 386 | + f"Create response missing GUID in OData-EntityId/Location headers (status={getattr(r,'status_code', '?')}). Headers: {header_keys}" |
| 387 | + ) |
| 388 | + |
| 389 | + def _create_multiple_with_metadata( |
| 390 | + self, entity_set: str, table_schema_name: str, records: List[Dict[str, Any]] |
| 391 | + ) -> tuple[List[str], RequestMetadata, Dict[str, Any]]: |
| 392 | + """Create multiple records and return GUIDs with metadata. |
| 393 | +
|
| 394 | + Same as :meth:`_create_multiple` but returns a tuple of |
| 395 | + (record_ids, RequestMetadata, batch_info) for the fluent API pattern. |
| 396 | +
|
| 397 | + :param entity_set: Resolved entity set (plural) name. |
| 398 | + :type entity_set: ``str`` |
| 399 | + :param table_schema_name: Schema name of the table. |
| 400 | + :type table_schema_name: ``str`` |
| 401 | + :param records: Payload dictionaries mapped by column schema names. |
| 402 | + :type records: ``list[dict[str, Any]]`` |
| 403 | + :return: Tuple of (list of created GUIDs, request metadata, batch info). |
| 404 | + :rtype: ``tuple[list[str], RequestMetadata, dict[str, Any]]`` |
| 405 | + """ |
| 406 | + if not all(isinstance(r, dict) for r in records): |
| 407 | + raise TypeError("All items for multi-create must be dicts") |
| 408 | + need_logical = any("@odata.type" not in r for r in records) |
| 409 | + logical_name = table_schema_name.lower() |
| 410 | + enriched: List[Dict[str, Any]] = [] |
| 411 | + for r in records: |
| 412 | + r = self._lowercase_keys(r) |
| 413 | + r = self._convert_labels_to_ints(table_schema_name, r) |
| 414 | + if "@odata.type" in r or not need_logical: |
| 415 | + enriched.append(r) |
| 416 | + else: |
| 417 | + nr = r.copy() |
| 418 | + nr["@odata.type"] = f"Microsoft.Dynamics.CRM.{logical_name}" |
| 419 | + enriched.append(nr) |
| 420 | + payload = {"Targets": enriched} |
| 421 | + url = f"{self.api}/{entity_set}/Microsoft.Dynamics.CRM.CreateMultiple" |
| 422 | + r, metadata = self._request_with_metadata("post", url, json=payload) |
| 423 | + |
| 424 | + try: |
| 425 | + body = r.json() if r.text else {} |
| 426 | + except ValueError: |
| 427 | + body = {} |
| 428 | + if not isinstance(body, dict): |
| 429 | + body = {} |
| 430 | + |
| 431 | + ids: List[str] = [] |
| 432 | + raw_ids = body.get("Ids") |
| 433 | + if isinstance(raw_ids, list): |
| 434 | + ids = [i for i in raw_ids if isinstance(i, str)] |
| 435 | + else: |
| 436 | + value = body.get("value") |
| 437 | + if isinstance(value, list): |
| 438 | + for item in value: |
| 439 | + if isinstance(item, dict): |
| 440 | + for k, v in item.items(): |
| 441 | + if isinstance(k, str) and k.lower().endswith("id") and isinstance(v, str) and len(v) >= 32: |
| 442 | + ids.append(v) |
| 443 | + break |
| 444 | + |
| 445 | + batch_info = { |
| 446 | + "total": len(records), |
| 447 | + "success": len(ids), |
| 448 | + "failures": len(records) - len(ids), |
| 449 | + } |
| 450 | + return ids, metadata, batch_info |
| 451 | + |
| 452 | + def _update_with_metadata( |
| 453 | + self, table_schema_name: str, key: str, data: Dict[str, Any] |
| 454 | + ) -> tuple[None, RequestMetadata]: |
| 455 | + """Update a single record and return metadata. |
| 456 | +
|
| 457 | + Same as :meth:`_update` but returns a tuple of (None, RequestMetadata) |
| 458 | + for the fluent API pattern. |
| 459 | +
|
| 460 | + :param table_schema_name: Schema name of the table. |
| 461 | + :type table_schema_name: ``str`` |
| 462 | + :param key: Record GUID. |
| 463 | + :type key: ``str`` |
| 464 | + :param data: Attribute changes. |
| 465 | + :type data: ``dict[str, Any]`` |
| 466 | + :return: Tuple of (None, request metadata). |
| 467 | + :rtype: ``tuple[None, RequestMetadata]`` |
| 468 | + """ |
| 469 | + data = self._lowercase_keys(data) |
| 470 | + data = self._convert_labels_to_ints(table_schema_name, data) |
| 471 | + entity_set = self._entity_set_from_schema_name(table_schema_name) |
| 472 | + url = f"{self.api}/{entity_set}({self._format_key(key)})" |
| 473 | + _, metadata = self._request_with_metadata("patch", url, json=data) |
| 474 | + return None, metadata |
| 475 | + |
| 476 | + def _delete_with_metadata( |
| 477 | + self, table_schema_name: str, key: str |
| 478 | + ) -> tuple[None, RequestMetadata]: |
| 479 | + """Delete a single record and return metadata. |
| 480 | +
|
| 481 | + Same as :meth:`_delete` but returns a tuple of (None, RequestMetadata) |
| 482 | + for the fluent API pattern. |
| 483 | +
|
| 484 | + :param table_schema_name: Schema name of the table. |
| 485 | + :type table_schema_name: ``str`` |
| 486 | + :param key: Record GUID. |
| 487 | + :type key: ``str`` |
| 488 | + :return: Tuple of (None, request metadata). |
| 489 | + :rtype: ``tuple[None, RequestMetadata]`` |
| 490 | + """ |
| 491 | + entity_set = self._entity_set_from_schema_name(table_schema_name) |
| 492 | + url = f"{self.api}/{entity_set}({self._format_key(key)})" |
| 493 | + _, metadata = self._request_with_metadata("delete", url) |
| 494 | + return None, metadata |
| 495 | + |
| 496 | + def _get_with_metadata( |
| 497 | + self, |
| 498 | + table_schema_name: str, |
| 499 | + key: str, |
| 500 | + *, |
| 501 | + select: Optional[List[str]] = None, |
| 502 | + ) -> tuple[Dict[str, Any], RequestMetadata]: |
| 503 | + """Get a single record by ID and return with metadata. |
| 504 | +
|
| 505 | + Same as :meth:`_get` but returns a tuple of (record, RequestMetadata) |
| 506 | + for the fluent API pattern. |
| 507 | +
|
| 508 | + :param table_schema_name: Schema name of the table. |
| 509 | + :type table_schema_name: ``str`` |
| 510 | + :param key: Record GUID. |
| 511 | + :type key: ``str`` |
| 512 | + :param select: Optional list of columns to select. |
| 513 | + :type select: ``list[str]`` | ``None`` |
| 514 | + :return: Tuple of (record dict, request metadata). |
| 515 | + :rtype: ``tuple[dict[str, Any], RequestMetadata]`` |
| 516 | + """ |
| 517 | + entity_set = self._entity_set_from_schema_name(table_schema_name) |
| 518 | + url = f"{self.api}/{entity_set}({self._format_key(key)})" |
| 519 | + if select: |
| 520 | + select = self._lowercase_list(select) |
| 521 | + url += "?$select=" + ",".join(select) |
| 522 | + r, metadata = self._request_with_metadata("get", url) |
| 523 | + try: |
| 524 | + return r.json(), metadata |
| 525 | + except ValueError: |
| 526 | + return {}, metadata |
| 527 | + |
254 | 528 | # --- CRUD Internal functions --- |
255 | 529 | def _create(self, entity_set: str, table_schema_name: str, record: Dict[str, Any]) -> str: |
256 | 530 | """Create a single record and return its GUID. |
|
0 commit comments