diff --git a/packages/uipath-platform/CLAUDE.md b/packages/uipath-platform/CLAUDE.md index 21896676f..b6d121bc2 100644 --- a/packages/uipath-platform/CLAUDE.md +++ b/packages/uipath-platform/CLAUDE.md @@ -98,7 +98,8 @@ Services provide both sync and async variants (e.g., `.invoke()` and `.invoke_as | `chat/` | LLM gateway, conversations, throttling | | `connections/` | External connection management | | `context_grounding/` | RAG services (DeepRAG, batch RAG, ephemeral indexes) | -| `documents/` | Document Understanding (IXP) | +| `document_projects/` | Document Understanding (IXP) **design-time** — projects, fields, taxonomy, labelling, deployments (`du_/api/designtimeapi`) | +| `documents/` | Document Understanding (IXP) **runtime** — extraction/classification (`du_/api/framework`) | | `entities/` | Data Service entity management | | `guardrails/` | LLM output guardrails | | `resource_catalog/` | Resource discovery and metadata | diff --git a/packages/uipath-platform/src/uipath/platform/document_projects/__init__.py b/packages/uipath-platform/src/uipath/platform/document_projects/__init__.py new file mode 100644 index 000000000..d3f563b21 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/document_projects/__init__.py @@ -0,0 +1,18 @@ +"""UiPath IXP design-time SDK (``du_/api/designtimeapi``). + +This package hosts the IXP design-time services (projects, taxonomy, labellings, +documents, models). It currently provides the shared base service; +resource services are added on top of :class:`IxpDesigntimeService`. +""" + +from ._base_service import ( + DESIGNTIME_API_BASE, + DESIGNTIME_API_VERSION, + IxpDesigntimeService, +) + +__all__ = [ + "IxpDesigntimeService", + "DESIGNTIME_API_BASE", + "DESIGNTIME_API_VERSION", +] diff --git a/packages/uipath-platform/src/uipath/platform/document_projects/_base_service.py b/packages/uipath-platform/src/uipath/platform/document_projects/_base_service.py new file mode 100644 index 000000000..e76ec714d --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/document_projects/_base_service.py @@ -0,0 +1,334 @@ +"""Base service for the IXP design-time API. + +All IXP design-time endpoints live under ``du_/api/designtimeapi`` and share a +small set of HTTP conventions that differ from the rest of the platform +SDK. This module centralises them in :class:`IxpDesigntimeService`, the base +class every IXP service (projects, taxonomy, labellings, documents, models) +builds on: + +* **Base path** — every request is prefixed with ``du_/api/designtimeapi`` and + scoped to ``{org}/{tenant}`` by :class:`BaseService`. +* **Mandatory api-version** — the gateway requires an explicit + ``api-version=1.0`` query parameter on *every* call. +* **Strict path encoding** — dynamic path segments (project name, field name, + tag, ...) may contain reserved characters, so each is percent-encoded with + ``quote(safe="")`` (``/`` -> ``%2F``, space -> ``%20``). +* **No retry on writes** — unlike the platform default, non-idempotent verbs + (POST/PUT/PATCH, including multipart upload) and DELETE are **not** retried, + so a transient 5xx cannot double-create or double-confirm a design-time + mutation. Only idempotent GETs keep the platform retry policy. +* **Multipart upload / binary download** — helpers for the ``file`` multipart + field and for reading raw bytes + content-type back. + +Errors surface as :class:`~uipath.platform.errors.EnrichedException`, which +already carries the status code and (truncated) response body as structured +fields. + +Note: IXP is org/tenant-scoped only — it has no Orchestrator folder concept — +so this base deliberately does **not** inherit ``FolderContext``. +""" + +from typing import Any, Optional, Union +from urllib.parse import quote + +from httpx import HTTPStatusError, Response +from tenacity import ( + retry, + retry_if_exception, + stop_after_attempt, +) + +from uipath.platform.constants import HEADER_USER_AGENT + +from ..common._base_service import BaseService, _inject_trace_context +from ..common._models import Endpoint +from ..common._user_agent import user_agent_value +from ..common.retry import ( + MAX_RETRY_ATTEMPTS, + is_retryable_platform_exception, + platform_wait_strategy, +) +from ..errors import EnrichedException + +#: API segment every design-time request is prefixed with. +DESIGNTIME_API_BASE = "du_/api/designtimeapi" + +#: The only api-version the gateway currently accepts; sent on every call. +DESIGNTIME_API_VERSION = "1.0" + + +class IxpDesigntimeService(BaseService): + """Base class for IXP design-time services (``du_/api/designtimeapi``). + + Subclasses build endpoints with :meth:`_endpoint` and issue requests with + the ``_get`` / ``_post`` / ``_put`` / ``_patch`` / ``_delete`` / ``_upload`` + / ``_download`` helpers (each has an ``_async`` twin). Every helper appends + the mandatory ``api-version`` query parameter; GETs are retried per the + platform policy while writes and deletes are not (see module docstring). + """ + + def _endpoint(self, template: str, **segments: Any) -> Endpoint: + """Build a design-time endpoint, percent-encoding dynamic segments. + + Args: + template: Path template rooted at the API (e.g. + ``"/api/projects/{name}/models/{version}/metrics"``). Fixed + segments are kept verbatim; ``{placeholder}`` values are filled + from ``segments``. + **segments: Values substituted into the template, each encoded with + ``quote(safe="")`` so reserved characters (``/``, spaces, ...) + survive as ``%2F`` / ``%20``. + + Returns: + The normalized ``Endpoint`` under ``du_/api/designtimeapi``. + """ + encoded = {key: quote(str(value), safe="") for key, value in segments.items()} + return Endpoint(f"/{DESIGNTIME_API_BASE}{template.format(**encoded)}") + + @staticmethod + def _params(params: Optional[dict[str, Any]]) -> dict[str, Any]: + """Merge the mandatory ``api-version`` into a request's query params.""" + return {"api-version": DESIGNTIME_API_VERSION, **(params or {})} + + @staticmethod + def _raise_for_status(response: Response) -> Response: + """Raise :class:`EnrichedException` on a non-2xx response.""" + try: + response.raise_for_status() + except HTTPStatusError as error: + raise EnrichedException(error) from error + return response + + def _headers(self) -> dict[str, str]: + """Per-request headers: user-agent + trace context (auth is on the client).""" + headers: dict[str, str] = { + HEADER_USER_AGENT: user_agent_value(self._specific_component) + } + _inject_trace_context(headers) + return headers + + # ── low-level send (no retry) ──────────────────────────────────────────── + + def _send( + self, + method: str, + endpoint: Union[Endpoint, str], + *, + params: Optional[dict[str, Any]] = None, + json: Optional[Any] = None, + files: Optional[dict[str, Any]] = None, + ) -> Response: + scoped_url = self._url.scope_url(str(endpoint), "tenant") + response = self._client.request( + method, + scoped_url, + params=self._params(params), + json=json, + files=files, + headers=self._headers(), + ) + return self._raise_for_status(response) + + async def _send_async( + self, + method: str, + endpoint: Union[Endpoint, str], + *, + params: Optional[dict[str, Any]] = None, + json: Optional[Any] = None, + files: Optional[dict[str, Any]] = None, + ) -> Response: + scoped_url = self._url.scope_url(str(endpoint), "tenant") + response = await self._client_async.request( + method, + scoped_url, + params=self._params(params), + json=json, + files=files, + headers=self._headers(), + ) + return self._raise_for_status(response) + + # ── retrying send (idempotent GET only) ────────────────────────────────── + + @retry( + retry=retry_if_exception(is_retryable_platform_exception), + wait=platform_wait_strategy, + stop=stop_after_attempt(MAX_RETRY_ATTEMPTS), + reraise=True, + ) + def _send_retrying( + self, + method: str, + endpoint: Union[Endpoint, str], + *, + params: Optional[dict[str, Any]] = None, + ) -> Response: + return self._send(method, endpoint, params=params) + + @retry( + retry=retry_if_exception(is_retryable_platform_exception), + wait=platform_wait_strategy, + stop=stop_after_attempt(MAX_RETRY_ATTEMPTS), + reraise=True, + ) + async def _send_retrying_async( + self, + method: str, + endpoint: Union[Endpoint, str], + *, + params: Optional[dict[str, Any]] = None, + ) -> Response: + return await self._send_async(method, endpoint, params=params) + + # ── verb helpers ───────────────────────────────────────────────────────── + + def _get( + self, endpoint: Union[Endpoint, str], *, params: Optional[dict[str, Any]] = None + ) -> Response: + """GET (idempotent — retried per the platform policy).""" + return self._send_retrying("GET", endpoint, params=params) + + async def _get_async( + self, endpoint: Union[Endpoint, str], *, params: Optional[dict[str, Any]] = None + ) -> Response: + """Async GET (idempotent — retried per the platform policy).""" + return await self._send_retrying_async("GET", endpoint, params=params) + + def _post( + self, + endpoint: Union[Endpoint, str], + *, + body: Optional[Any] = None, + params: Optional[dict[str, Any]] = None, + ) -> Response: + """POST (not retried). A ``None`` body is sent as ``{}`` (an empty JSON object).""" + return self._send( + "POST", endpoint, params=params, json=body if body is not None else {} + ) + + async def _post_async( + self, + endpoint: Union[Endpoint, str], + *, + body: Optional[Any] = None, + params: Optional[dict[str, Any]] = None, + ) -> Response: + """Async POST (not retried). A ``None`` body is sent as ``{}``.""" + return await self._send_async( + "POST", endpoint, params=params, json=body if body is not None else {} + ) + + def _put( + self, + endpoint: Union[Endpoint, str], + *, + body: Optional[Any] = None, + params: Optional[dict[str, Any]] = None, + ) -> Response: + """PUT (not retried). A ``None`` body is sent as ``{}``.""" + return self._send( + "PUT", endpoint, params=params, json=body if body is not None else {} + ) + + async def _put_async( + self, + endpoint: Union[Endpoint, str], + *, + body: Optional[Any] = None, + params: Optional[dict[str, Any]] = None, + ) -> Response: + """Async PUT (not retried). A ``None`` body is sent as ``{}``.""" + return await self._send_async( + "PUT", endpoint, params=params, json=body if body is not None else {} + ) + + def _patch( + self, + endpoint: Union[Endpoint, str], + *, + body: Optional[Any] = None, + params: Optional[dict[str, Any]] = None, + ) -> Response: + """PATCH (not retried). A ``None`` body is sent as ``{}``.""" + return self._send( + "PATCH", endpoint, params=params, json=body if body is not None else {} + ) + + async def _patch_async( + self, + endpoint: Union[Endpoint, str], + *, + body: Optional[Any] = None, + params: Optional[dict[str, Any]] = None, + ) -> Response: + """Async PATCH (not retried). A ``None`` body is sent as ``{}``.""" + return await self._send_async( + "PATCH", endpoint, params=params, json=body if body is not None else {} + ) + + def _delete( + self, endpoint: Union[Endpoint, str], *, params: Optional[dict[str, Any]] = None + ) -> Response: + """DELETE (not retried — avoids a spurious 404 from a retried delete).""" + return self._send("DELETE", endpoint, params=params) + + async def _delete_async( + self, endpoint: Union[Endpoint, str], *, params: Optional[dict[str, Any]] = None + ) -> Response: + """Async DELETE (not retried).""" + return await self._send_async("DELETE", endpoint, params=params) + + def _upload( + self, + endpoint: Union[Endpoint, str], + *, + filename: str, + content: Any, + content_type: str = "application/octet-stream", + params: Optional[dict[str, Any]] = None, + ) -> Response: + """Multipart upload of a single file under the ``file`` field (not retried).""" + return self._send( + "POST", + endpoint, + params=params, + files={"file": (filename, content, content_type)}, + ) + + async def _upload_async( + self, + endpoint: Union[Endpoint, str], + *, + filename: str, + content: Any, + content_type: str = "application/octet-stream", + params: Optional[dict[str, Any]] = None, + ) -> Response: + """Async multipart upload under the ``file`` field (not retried).""" + return await self._send_async( + "POST", + endpoint, + params=params, + files={"file": (filename, content, content_type)}, + ) + + def _download( + self, endpoint: Union[Endpoint, str], *, params: Optional[dict[str, Any]] = None + ) -> tuple[bytes, str]: + """GET raw bytes, returning ``(content, content_type)``.""" + response = self._send_retrying("GET", endpoint, params=params) + content_type = ( + response.headers.get("content-type") or "application/octet-stream" + ) + return response.content, content_type + + async def _download_async( + self, endpoint: Union[Endpoint, str], *, params: Optional[dict[str, Any]] = None + ) -> tuple[bytes, str]: + """Async GET raw bytes, returning ``(content, content_type)``.""" + response = await self._send_retrying_async("GET", endpoint, params=params) + content_type = ( + response.headers.get("content-type") or "application/octet-stream" + ) + return response.content, content_type diff --git a/packages/uipath-platform/tests/services/test_document_projects_base_service.py b/packages/uipath-platform/tests/services/test_document_projects_base_service.py new file mode 100644 index 000000000..fe83fb530 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_document_projects_base_service.py @@ -0,0 +1,208 @@ +"""Tests for the IXP design-time base service (``platform/document_projects/_base_service``). + +Cover the design-time HTTP conventions this layer enforces: +the ``du_/api/designtimeapi`` base path, the mandatory ``api-version=1.0`` query +param, strict path-segment percent-encoding, ``{}`` as the empty write body, +multipart ``file`` upload, binary download, and — the load-bearing one — that +writes are NOT retried while idempotent GETs are. +""" + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.document_projects import IxpDesigntimeService +from uipath.platform.errors import EnrichedException + + +@pytest.fixture +def ixp_service( + config: UiPathApiConfig, execution_context: UiPathExecutionContext +) -> IxpDesigntimeService: + return IxpDesigntimeService(config=config, execution_context=execution_context) + + +class TestDesigntimeUrl: + def test_get_builds_designtime_url_with_api_version( + self, + httpx_mock: HTTPXMock, + ixp_service: IxpDesigntimeService, + ) -> None: + httpx_mock.add_response(json={"value": []}) + + ixp_service._get(ixp_service._endpoint("/api/projects")) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/org/tenant/du_/api/designtimeapi/api/projects" + assert request.url.params.get("api-version") == "1.0" + + def test_path_segments_are_percent_encoded( + self, + httpx_mock: HTTPXMock, + ixp_service: IxpDesigntimeService, + ) -> None: + httpx_mock.add_response(json={}) + + # A project name with a space and a slash must survive as %20 / %2F — + # not split into extra path segments, not double-encoded. + ixp_service._get(ixp_service._endpoint("/api/projects/{name}", name="a b/c")) + + request = httpx_mock.get_request() + assert request is not None + assert b"/api/projects/a%20b%2Fc" in request.url.raw_path + + +class TestWriteBody: + def test_post_sends_empty_object_when_no_body( + self, + httpx_mock: HTTPXMock, + ixp_service: IxpDesigntimeService, + ) -> None: + httpx_mock.add_response(json={}) + + ixp_service._post( + ixp_service._endpoint("/api/projects/{name}/prompt", name="p") + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.content == b"{}" + assert request.headers["content-type"] == "application/json" + + +class TestRetryPolicy: + def test_post_not_retried_on_transient_error( + self, + httpx_mock: HTTPXMock, + ixp_service: IxpDesigntimeService, + ) -> None: + # 502 is a normally-retryable status, but writes must not be retried. + httpx_mock.add_response(status_code=502) + + with pytest.raises(EnrichedException) as exc_info: + ixp_service._post( + ixp_service._endpoint("/api/projects"), body={"Name": "p"} + ) + + assert exc_info.value.status_code == 502 + assert len(httpx_mock.get_requests()) == 1 + + def test_delete_not_retried_on_transient_error( + self, + httpx_mock: HTTPXMock, + ixp_service: IxpDesigntimeService, + ) -> None: + httpx_mock.add_response(status_code=502) + + with pytest.raises(EnrichedException) as exc_info: + ixp_service._delete(ixp_service._endpoint("/api/projects/{name}", name="p")) + + assert exc_info.value.status_code == 502 + assert len(httpx_mock.get_requests()) == 1 + + def test_get_retried_on_transient_error( + self, + httpx_mock: HTTPXMock, + ixp_service: IxpDesigntimeService, + ) -> None: + # retry-after: 0 keeps the test fast while still exercising the retry. + httpx_mock.add_response(status_code=429, headers={"retry-after": "0"}) + httpx_mock.add_response(status_code=200, json={"value": []}) + + response = ixp_service._get(ixp_service._endpoint("/api/projects")) + + assert response.status_code == 200 + assert len(httpx_mock.get_requests()) == 2 + + +class TestUploadDownload: + def test_upload_uses_file_multipart_field( + self, + httpx_mock: HTTPXMock, + ixp_service: IxpDesigntimeService, + ) -> None: + httpx_mock.add_response(json={"DocumentId": "d", "AttachmentRef": "r"}) + + ixp_service._upload( + ixp_service._endpoint("/api/projects/{name}/documents", name="p"), + filename="invoice.pdf", + content=b"%PDF-1.7 data", + content_type="application/pdf", + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.headers["content-type"].startswith("multipart/form-data") + assert b'name="file"' in request.content + assert b'filename="invoice.pdf"' in request.content + assert b"%PDF-1.7 data" in request.content + + def test_download_returns_content_and_content_type( + self, + httpx_mock: HTTPXMock, + ixp_service: IxpDesigntimeService, + ) -> None: + httpx_mock.add_response( + content=b"\x89PNG\r\n", headers={"content-type": "image/png"} + ) + + content, content_type = ixp_service._download( + ixp_service._endpoint( + "/api/projects/{name}/documents/{doc}", name="p", doc="d" + ) + ) + + assert content == b"\x89PNG\r\n" + assert content_type == "image/png" + + def test_download_falls_back_to_octet_stream( + self, + httpx_mock: HTTPXMock, + ixp_service: IxpDesigntimeService, + ) -> None: + httpx_mock.add_response(content=b"bytes", headers={}) + + _content, content_type = ixp_service._download( + ixp_service._endpoint( + "/api/projects/{name}/documents/{doc}", name="p", doc="d" + ) + ) + + assert content_type == "application/octet-stream" + + +class TestAsync: + @pytest.mark.anyio + async def test_get_async_builds_designtime_url( + self, + httpx_mock: HTTPXMock, + ixp_service: IxpDesigntimeService, + ) -> None: + httpx_mock.add_response(json={"value": []}) + + await ixp_service._get_async(ixp_service._endpoint("/api/projects")) + + request = httpx_mock.get_request() + assert request is not None + assert request.url.path == "/org/tenant/du_/api/designtimeapi/api/projects" + assert request.url.params.get("api-version") == "1.0" + + @pytest.mark.anyio + async def test_post_async_not_retried_on_transient_error( + self, + httpx_mock: HTTPXMock, + ixp_service: IxpDesigntimeService, + ) -> None: + httpx_mock.add_response(status_code=502) + + with pytest.raises(EnrichedException) as exc_info: + await ixp_service._post_async( + ixp_service._endpoint("/api/projects"), body={"Name": "p"} + ) + + assert exc_info.value.status_code == 502 + assert len(httpx_mock.get_requests()) == 1