diff --git a/packages/uipath-platform/CLAUDE.md b/packages/uipath-platform/CLAUDE.md index 21896676f..368b9fa1e 100644 --- a/packages/uipath-platform/CLAUDE.md +++ b/packages/uipath-platform/CLAUDE.md @@ -48,6 +48,7 @@ sdk.agenthub # AgentHubService sdk.mcp # McpService sdk.resource_catalog # ResourceCatalogService sdk.automation_tracker # AutomationTrackerService +sdk.business_rules # BusinessRulesService ``` ### Authentication @@ -95,6 +96,7 @@ Services provide both sync and async variants (e.g., `.invoke()` and `.invoke_as | `action_center/` | Task management for human-in-the-loop workflows | | `agenthub/` | System agents and LLM model discovery | | `automation_tracker/` | Business Transaction Service (BTS) for Process Mining | +| `business_rules/` | Business rule runs for rules deployed to Orchestrator, behind one `run()` | | `chat/` | LLM gateway, conversations, throttling | | `connections/` | External connection management | | `context_grounding/` | RAG services (DeepRAG, batch RAG, ephemeral indexes) | diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 091bd2284..be322ea1e 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.34" +version = "0.2.35" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 98af7b8b6..52f392e66 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -9,6 +9,7 @@ from .agenthub._agenthub_service import AgentHubService from .agenthub._remote_a2a_service import RemoteA2aService from .automation_ops import AutomationOpsService +from .business_rules import BusinessRulesService from .chat import ConversationsService, UiPathLlmChatService, UiPathOpenAIService from .common import ( ApiClient, @@ -190,6 +191,10 @@ def orchestrator_setup(self) -> OrchestratorSetupService: def automation_ops(self) -> AutomationOpsService: return AutomationOpsService(self._config, self._execution_context) + @property + def business_rules(self) -> BusinessRulesService: + return BusinessRulesService(self._config, self._execution_context, self.folders) + @property def pii_detection(self) -> PiiDetectionService: return PiiDetectionService(self._config, self._execution_context) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py new file mode 100644 index 000000000..b49fd91cf --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py @@ -0,0 +1,26 @@ +"""Business Rules service package. + +Provides the ``BusinessRulesService`` client for running UiPath Business Rules +deployed to Orchestrator, and the Pydantic models for its caller, results and +trace context. +""" + +from ._business_rules_service import BusinessRulesService +from .business_rules import ( + BusinessRuleCaller, + BusinessRuleDecision, + BusinessRuleError, + BusinessRuleRunResult, + BusinessRuleStatus, + TraceContext, +) + +__all__ = [ + "BusinessRuleCaller", + "BusinessRuleDecision", + "BusinessRuleError", + "BusinessRuleRunResult", + "BusinessRuleStatus", + "BusinessRulesService", + "TraceContext", +] diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py new file mode 100644 index 000000000..65df6bdc8 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py @@ -0,0 +1,351 @@ +"""Business Rules service for UiPath Platform. + +Runs business rules deployed to Orchestrator. +""" + +from collections.abc import Mapping +from typing import Any, Dict, List, Optional, Tuple + +from uipath.core.tracing import traced + +from ..common._base_service import _TRACE_PARENT_HEADER, BaseService +from ..common._bindings import resource_override +from ..common._config import UiPathApiConfig, UiPathConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext +from ..common._models import Endpoint, RequestSpec +from ..constants import HEADER_FOLDER_KEY +from ..orchestrator._folder_service import FolderService +from .business_rules import ( + BusinessRuleCaller, + BusinessRuleDecision, + BusinessRuleError, + BusinessRuleRunResult, + BusinessRuleStatus, + TraceContext, + _WireResponse, +) + +_EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/evaluate") + +_MAX_INPUT_KEYS = 256 +_MAX_RULE_NAME_LENGTH = 256 + + +class BusinessRulesService(FolderContext, BaseService): + """Service for running UiPath Business Rules. + + Each call runs one input against a business rule deployed to Orchestrator, + named like any other resource, and returns the decisions it produced. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: FolderService, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + + @traced(name="business_rules_run", run_type="uipath") + def run( + self, + name: str, + input: Dict[str, Any], + *, + version: Optional[str] = None, + decision_names: Optional[List[str]] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + caller: Optional[BusinessRuleCaller] = None, + trace_context: Optional[TraceContext] = None, + ) -> BusinessRuleRunResult: + """Run a business rule against one input. + + Args: + name: The name of the business rule. + input: The input to run, keyed by the rule's input names. Declared + inputs absent from it bind to null. + version: The rule version to run; defaults to the active version. + decision_names: The decisions to evaluate; defaults to the whole model. + folder_key: The key of the folder the rule is deployed in. + folder_path: The path of the folder the rule is deployed in. Looked up + and sent as its key. + caller: Who is running the rule, for the deployed run's audit. + Fields left unset default to the current job's values. + trace_context: The trace to file the run's spans under. Defaults to + the ambient trace: ``UIPATH_TRACE_ID`` and the current span. + + The rule runs in a folder named by its key: ``folder_key`` is sent as is, + and ``folder_path`` is looked up and sent as its key; the two are + exclusive. When the caller gives neither, it falls back to + ``UIPATH_FOLDER_KEY`` and then ``UIPATH_FOLDER_PATH``. A ``businessRule`` + binding can remap ``name`` and the folder per environment; its folder then + replaces ``folder_key`` or ``folder_path``. + + Returns: + BusinessRuleRunResult: The decisions produced for the input. + + Raises: + ValueError: If the request is invalid or a required folder is missing. + EnrichedException: If the service rejects the request. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + + result = client.business_rules.run( + "Loan Pricing", + {"creditScore": 740, "age": 34}, + folder_path="Finance", + ) + for decision in result.decisions: + print(decision.decision_name, decision.outputs) + ``` + """ + name, folder_key, folder_path = self._apply_binding( + name, folder_key, folder_path + ) + _validate_run(name, input) + key, path = self._folder_source(folder_key, folder_path) + if path: + key = self._folders_service.retrieve_folder_key(path) + spec = _evaluate_spec(name, input, version, decision_names, key, caller) + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=_with_trace(spec.headers, trace_context), + scoped="tenant", + ) + return _to_run_result(_WireResponse.model_validate(response.json())) + + @traced(name="business_rules_run", run_type="uipath") + async def run_async( + self, + name: str, + input: Dict[str, Any], + *, + version: Optional[str] = None, + decision_names: Optional[List[str]] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + caller: Optional[BusinessRuleCaller] = None, + trace_context: Optional[TraceContext] = None, + ) -> BusinessRuleRunResult: + """Asynchronously run a business rule against one input. + + Args: + name: The name of the business rule. + input: The input to run, keyed by the rule's input names. Declared + inputs absent from it bind to null. + version: The rule version to run; defaults to the active version. + decision_names: The decisions to evaluate; defaults to the whole model. + folder_key: The key of the folder the rule is deployed in. + folder_path: The path of the folder the rule is deployed in. Looked up + and sent as its key. + caller: Who is running the rule, for the deployed run's audit. + Fields left unset default to the current job's values. + trace_context: The trace to file the run's spans under. Defaults to + the ambient trace: ``UIPATH_TRACE_ID`` and the current span. + + Returns: + BusinessRuleRunResult: The decisions produced for the input. + + Raises: + ValueError: If the request is invalid or a required folder is missing. + EnrichedException: If the service rejects the request. + """ + name, folder_key, folder_path = self._apply_binding( + name, folder_key, folder_path + ) + _validate_run(name, input) + key, path = self._folder_source(folder_key, folder_path) + if path: + key = await self._folders_service.retrieve_folder_key_async(path) + spec = _evaluate_spec(name, input, version, decision_names, key, caller) + response = await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=_with_trace(spec.headers, trace_context), + scoped="tenant", + ) + return _to_run_result(_WireResponse.model_validate(response.json())) + + @resource_override(resource_type="businessRule") + def _binding( + self, name: str, folder_path: Optional[str] = None + ) -> Tuple[str, Optional[str]]: + # resource_override swaps these two arguments when the solution's + # bindings remap this rule; the method just returns what it was given. + return name, folder_path + + def _apply_binding( + self, name: str, folder_key: Optional[str], folder_path: Optional[str] + ) -> Tuple[str, Optional[str], Optional[str]]: + """Apply a businessRule binding, if one remaps this rule. + + The binding names a folder by path. When it applies, that folder + replaces whichever folder the caller gave, including a folder_key, which + the override decorator alone would leave in place next to the new path. + """ + bound_name, bound_path = self._binding(name, folder_path=folder_path) + if (bound_name, bound_path) != (name, folder_path) and bound_path: + folder_key = None + return bound_name, folder_key, bound_path + + def _folder_source( + self, folder_key: Optional[str], folder_path: Optional[str] + ) -> Tuple[Optional[str], Optional[str]]: + """Pick the folder key to send, as a (key, path-still-to-resolve) pair.""" + # Blank counts as not given, so it falls back to the environment. + has_key, has_path = _present(folder_key), _present(folder_path) + if has_key and has_path: + raise ValueError("Only one of folder_key or folder_path can be provided") + if has_key: + return folder_key, None + if has_path: + return None, folder_path + if self._folder_key: + return self._folder_key, None + if self._folder_path: + return None, self._folder_path + raise ValueError( + "A folder is required for a deployed business rule: pass folder_key or " + "folder_path, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" + ) + + +def _evaluate_spec( + name: str, + input: Dict[str, Any], + version: Optional[str], + decision_names: Optional[List[str]], + folder_key: Optional[str], + caller: Optional[BusinessRuleCaller], +) -> RequestSpec: + # The service resolves the rule in this folder and files the run's trace and + # audit record under it; a folder path that resolves to nothing lands here. + if not folder_key or not folder_key.strip(): + raise ValueError(f"No folder key was found for business rule '{name}'") + body: Dict[str, Any] = {"businessRuleName": name, "input": input} + if _present(version): + body["version"] = version + if decision_names: + body["decisionNames"] = decision_names + wire_caller = _wire_caller(caller) + if wire_caller: + body["caller"] = wire_caller + return RequestSpec( + method="POST", + endpoint=_EVALUATE_ENDPOINT, + json=body, + headers={HEADER_FOLDER_KEY: folder_key}, + ) + + +def _wire_caller(caller: Optional[BusinessRuleCaller]) -> Dict[str, str]: + given = caller or BusinessRuleCaller() + values = { + "resourceKey": given.resource_key or UiPathConfig.process_uuid, + "runKey": given.run_key or UiPathConfig.job_key, + "folderKey": given.folder_key or UiPathConfig.folder_key, + } + # Blank fields are left out, and a caller with every field blank is no caller. + wire: Dict[str, str] = {} + for key, value in values.items(): + if value and value.strip(): + wire[key] = value + return wire + + +class _TraceHeaders(Dict[str, str]): + """Request headers that keep the caller's explicit trace header. + + BaseService writes the ambient trace header into the headers it is given just + before sending; this dict ignores that write when an explicit one is set. + """ + + def __setitem__(self, key: str, value: str) -> None: + if key == _TRACE_PARENT_HEADER and key in self: + return + super().__setitem__(key, value) + + +def _with_trace( + headers: Dict[str, str], trace_context: Optional[TraceContext] +) -> Dict[str, str]: + if trace_context is None: + return headers + pinned = _TraceHeaders(headers) + dict.__setitem__(pinned, _TRACE_PARENT_HEADER, trace_context.to_traceparent()) + return pinned + + +def _present(value: Optional[str]) -> bool: + # Blank counts as absent, matching how the service reads these fields. + return bool(value and value.strip()) + + +def _validate_run(name: str, input: Dict[str, Any]) -> None: + _validate_rule_name(name, "name") + _validate_input(input) + + +def _validate_rule_name(rule_name: str, field: str) -> None: + if not _present(rule_name): + raise ValueError(f"{field} must be specified") + if len(rule_name) > _MAX_RULE_NAME_LENGTH: + raise ValueError(f"{field} must not exceed {_MAX_RULE_NAME_LENGTH} characters") + for forbidden in ("/", "\\", "..", "%"): + if forbidden in rule_name: + raise ValueError(f"{field} must not contain '{forbidden}'") + if any(not ch.isprintable() for ch in rule_name): + raise ValueError(f"{field} must not contain control characters") + + +def _validate_input(input: Dict[str, Any]) -> None: + if input is None: + raise ValueError("input must not be None") + if not isinstance(input, Mapping): + raise ValueError( + "input must be a mapping of the rule's input names to values, " + f"not {type(input).__name__}" + ) + if len(input) > _MAX_INPUT_KEYS: + raise ValueError(f"input must not exceed {_MAX_INPUT_KEYS} keys") + + +def _status( + decisions: List[BusinessRuleDecision], errors: List[BusinessRuleError] +) -> BusinessRuleStatus: + # An input-level error means the input never evaluated, whatever else came back. + if errors: + return BusinessRuleStatus.ALL_FAILED + failed = sum(1 for d in decisions if d.error is not None) + if failed == 0: + return BusinessRuleStatus.SUCCESS + if failed == len(decisions): + return BusinessRuleStatus.ALL_FAILED + return BusinessRuleStatus.PARTIAL_SUCCESS + + +def _to_run_result(response: _WireResponse) -> BusinessRuleRunResult: + # A successful response always carries the input's result; one without it is + # not an answer to report as an evaluation. + if response.result is None: + raise ValueError("The business rules response did not include a result") + decisions = response.result.decisions or [] + errors = response.result.errors or [] + return BusinessRuleRunResult( + status=_status(decisions, errors), + decisions=decisions, + errors=errors, + top_level_error=response.error.code if response.error else None, + business_rule_name=response.business_rule_name, + version=response.version, + ) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py new file mode 100644 index 000000000..44eeb468b --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py @@ -0,0 +1,150 @@ +"""Models for the UiPath Business Rules service.""" + +import re +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +_HEX = re.compile(r"^[0-9a-f]+$") + + +class BusinessRuleStatus(str, Enum): + """The overall outcome of running a business rule against one input.""" + + SUCCESS = "Success" + PARTIAL_SUCCESS = "PartialSuccess" + ALL_FAILED = "AllFailed" + + +class BusinessRuleCaller(BaseModel): + """Who is running the rule, kept in the deployed run's execution audit. + + Each field defaults to the current job's value from the environment. This + describes the calling resource, not the rule: ``folder_key`` here is the + caller's folder, which can differ from the folder the rule runs in. + """ + + resource_key: Optional[str] = Field( + default=None, + description="The calling resource's key; for a process, its release key. Defaults to UIPATH_PROCESS_UUID.", + ) + run_key: Optional[str] = Field( + default=None, + description="The calling run, such as its job key. Defaults to UIPATH_JOB_KEY.", + ) + folder_key: Optional[str] = Field( + default=None, + description="The folder the calling resource runs in. Defaults to UIPATH_FOLDER_KEY.", + ) + + +class TraceContext(BaseModel): + """An existing trace to file the run's spans under. + + Optional on ``run()``: when omitted, the SDK takes the trace from + ``UIPATH_TRACE_ID`` and the current span, as it does for every service. + """ + + trace_id: str = Field( + description="The trace id: 32 hex characters, or a UUID with dashes." + ) + parent_span_id: str = Field( + description="The span the run's spans nest under: 16 hex characters." + ) + + @field_validator("trace_id") + @classmethod + def _normalize_trace_id(cls, value: str) -> str: + normalized = value.replace("-", "").strip().lower() + if len(normalized) != 32 or not _HEX.match(normalized): + raise ValueError("trace_id must be 32 hex characters or a UUID") + if normalized == "0" * 32: + raise ValueError("trace_id must not be all zeros") + return normalized + + @field_validator("parent_span_id") + @classmethod + def _normalize_parent_span_id(cls, value: str) -> str: + normalized = value.strip().lower() + if len(normalized) != 16 or not _HEX.match(normalized): + raise ValueError("parent_span_id must be 16 hex characters") + if normalized == "0" * 16: + raise ValueError("parent_span_id must not be all zeros") + return normalized + + def to_traceparent(self) -> str: + """Return the W3C traceparent value for this context.""" + return f"00-{self.trace_id}-{self.parent_span_id}-01" + + +class BusinessRuleError(BaseModel): + """A code/message pair describing an input-level or decision-level error.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + extra="allow", + ) + + code: str = Field(default="", alias="code") + message: str = Field(default="", alias="message") + + +class BusinessRuleDecision(BaseModel): + """The result of evaluating a single decision.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + extra="allow", + ) + + decision_name: str = Field(default="", alias="decisionName") + outputs: Optional[Dict[str, Any]] = Field(default=None, alias="outputs") + error: Optional[BusinessRuleError] = Field(default=None, alias="error") + + +class BusinessRuleRunResult(BaseModel): + """The outcome of running a business rule against one input.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + ) + + status: BusinessRuleStatus + decisions: List[BusinessRuleDecision] = Field(default_factory=list) + errors: List[BusinessRuleError] = Field( + default_factory=list, + description="Errors that failed the input as a whole; non-empty means nothing was evaluated.", + ) + top_level_error: Optional[str] = Field( + default=None, + description="The request-level error code, if one was reported.", + ) + business_rule_name: Optional[str] = Field( + default=None, description="The deployed rule that ran, when reported." + ) + version: Optional[str] = Field( + default=None, description="The rule version that ran, when reported." + ) + + +class _WireResult(BaseModel): + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + decisions: Optional[List[BusinessRuleDecision]] = None + errors: Optional[List[BusinessRuleError]] = None + + +class _WireResponse(BaseModel): + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="ignore" + ) + + business_rule_name: Optional[str] = Field(default=None, alias="businessRuleName") + version: Optional[str] = None + error: Optional[BusinessRuleError] = None + result: Optional[_WireResult] = None diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index a93880896..e764ee587 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -61,6 +61,7 @@ class GenericResourceOverwrite(ResourceOverwrite): "queue", "remoteA2aAgent", "memorySpace", + "businessRule", ] name: str = Field(alias="name") folder_path: str = Field(alias="folderPath") diff --git a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py new file mode 100644 index 000000000..9d0fddf1e --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_businessrules.py @@ -0,0 +1,25 @@ +"""Business Rules service error payload extractor. + +Business Rules returns: {"error": {"code": "", "message": ""}, +"meta": {...}}, with the code nested under "error" where the generic extractor +does not look for it. +""" + +from typing import Any + +from .._enriched_exception import ExtractedErrorInfo +from ._generic import extract_generic +from ._helpers import get_field, get_str_field, get_typed_field + + +def extract_businessrules(body: dict[str, Any]) -> ExtractedErrorInfo: + error = get_field(body, "error") + if not isinstance(error, dict): + # Not the service's envelope, e.g. an error answered by the gateway. + return extract_generic(body) + + return ExtractedErrorInfo( + message=get_typed_field(error, str, "message"), + error_code=get_str_field(error, "code"), + trace_id=get_typed_field(body, str, "traceId", "requestId"), + ) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py index 901dbc089..4e52eac5f 100644 --- a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py +++ b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py @@ -14,6 +14,7 @@ from .._enriched_exception import ExtractedErrorInfo from ._agenthub import extract_agenthub from ._apps import extract_apps +from ._businessrules import extract_businessrules from ._datafabric import extract_datafabric from ._elements import extract_elements from ._generic import extract_generic @@ -30,6 +31,7 @@ "agenthub_": extract_agenthub, "agentsruntime_": extract_agenthub, "apps_": extract_apps, + "businessrules_": extract_businessrules, "datafabric_": extract_datafabric, "elements_": extract_elements, "llmopstenant_": extract_llmops, diff --git a/packages/uipath-platform/tests/errors/test_enriched_exception.py b/packages/uipath-platform/tests/errors/test_enriched_exception.py index e41f41007..3ce6e6500 100644 --- a/packages/uipath-platform/tests/errors/test_enriched_exception.py +++ b/packages/uipath-platform/tests/errors/test_enriched_exception.py @@ -9,6 +9,9 @@ _LLM_URL = "https://cloud.uipath.com/org/tenant/orchestrator_/llm/chat" _AGENTHUB_URL = "https://cloud.uipath.com/org/tenant/agenthub_/api/v1" _APPS_URL = "https://cloud.uipath.com/org/tenant/apps_/api/v1" +_BUSINESS_RULES_URL = ( + "https://cloud.uipath.com/org/tenant/businessrules_/v1/business-rules/evaluate" +) _ELEMENTS_URL = "https://cloud.uipath.com/org/tenant/elements_/api/v1" _LLMOPS_URL = "https://cloud.uipath.com/org/tenant/llmopstenant_/api/v1" _GENERIC_URL = "https://cloud.uipath.com/api/test" @@ -239,6 +242,39 @@ def test_error_message_v2_priority(self): assert exc.error_info.message == "Detailed error" +class TestBusinessRulesExtraction: + """Business Rules nests the code and message under "error".""" + + def test_nested_error_code_and_message(self): + body = json.dumps( + { + "businessRuleName": "Missing", + "error": {"code": "RULE_NOT_FOUND", "message": "no such rule"}, + "meta": {"timestamp": "2026-10-07T00:00:00Z"}, + } + ) + exc = EnrichedException(_make_error(404, body=body, url=_BUSINESS_RULES_URL)) + assert exc.error_info is not None + assert exc.error_info.message == "no such rule" + assert exc.error_info.error_code == "RULE_NOT_FOUND" + assert exc.error_info.trace_id is None + + def test_error_without_message(self): + body = json.dumps({"error": {"code": "BATCH_TIMEOUT"}, "meta": {}}) + exc = EnrichedException(_make_error(504, body=body, url=_BUSINESS_RULES_URL)) + assert exc.error_info is not None + assert exc.error_info.error_code == "BATCH_TIMEOUT" + assert exc.error_info.message is None + + def test_other_shapes_fall_back_to_generic(self): + # An error answered by the gateway rather than the service. + body = json.dumps({"message": "Unauthorized", "errorCode": 1015}) + exc = EnrichedException(_make_error(401, body=body, url=_BUSINESS_RULES_URL)) + assert exc.error_info is not None + assert exc.error_info.message == "Unauthorized" + assert exc.error_info.error_code == "1015" + + class TestElementsExtraction: """Elements has vendor passthrough fields (providerMessage, providerErrorCode).""" diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py new file mode 100644 index 000000000..df91d1d1e --- /dev/null +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -0,0 +1,857 @@ +import json +from typing import Any, Iterator +from unittest.mock import AsyncMock, Mock + +import pytest +from opentelemetry import trace +from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags +from pydantic import ValidationError +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.business_rules import ( + BusinessRuleCaller, + BusinessRulesService, + BusinessRuleStatus, + TraceContext, +) +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + ResourceOverwriteParser, + _resource_overwrites, +) +from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT +from uipath.platform.errors import EnrichedException + +FOLDER_KEY = "5f1f1b0e-2b8a-4c1e-9b8e-1a2b3c4d5e6f" +TRACEPARENT = "x-uipath-traceparent-id" +RULE = "Loan Pricing" +JOB_KEY = "9d8c7b6a-5f4e-3d2c-1b0a-9f8e7d6c5b4a" + + +@pytest.fixture +def folders_service() -> Mock: + folders = Mock() + folders.retrieve_folder_key.return_value = FOLDER_KEY + folders.retrieve_folder_key_async = AsyncMock(return_value=FOLDER_KEY) + return folders + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> BusinessRulesService: + monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False) + monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) + monkeypatch.delenv("UIPATH_PROCESS_UUID", raising=False) + return BusinessRulesService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + + +@pytest.fixture +def evaluate_url(base_url: str, org: str, tenant: str) -> str: + return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/evaluate" + + +def _response(result: dict[str, Any] | None = None, **extra: Any) -> dict[str, Any]: + body: dict[str, Any] = {"meta": {"timestamp": "2026-09-28T00:00:00Z"}, **extra} + body["result"] = {"decisions": []} if result is None else result + return body + + +def _one_decision(**outputs: Any) -> dict[str, Any]: + return {"decisions": [{"decisionName": "RiskGrade", "outputs": outputs}]} + + +class TestRunContext: + @pytest.mark.parametrize( + "rule_name", + ["", " ", "a/b", "a\\b", "a..b", "a%20b", "a\nb", "x" * 257], + ) + def test_rejects_unsafe_rule_names( + self, service: BusinessRulesService, rule_name: str + ) -> None: + with pytest.raises(ValueError, match="name"): + service.run(rule_name, {}, folder_key=FOLDER_KEY) + + @pytest.mark.parametrize("value", [["age", 14], "age=14", 14]) + def test_rejects_non_mapping_input( + self, httpx_mock: HTTPXMock, service: BusinessRulesService, value: Any + ) -> None: + with pytest.raises(ValueError, match="input must be a mapping"): + service.run(RULE, value, folder_key=FOLDER_KEY) + + assert httpx_mock.get_requests() == [] + + def test_rejects_oversized_input(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="256 keys"): + service.run(RULE, {f"k{i}": i for i in range(257)}, folder_key=FOLDER_KEY) + + +class TestFolder: + def test_resolves_folder_path_to_key( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, folder_path="Finance/Loans") + + folders_service.retrieve_folder_key.assert_called_once_with("Finance/Loans") + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert "x-uipath-folderpath" not in request.headers + + def test_falls_back_to_env_folder_key( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}) + + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" + + def test_falls_back_to_env_folder_path( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}) + + folders_service.retrieve_folder_key.assert_called_once_with("Finance/Loans") + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert "x-uipath-folderpath" not in request.headers + + async def test_falls_back_to_env_folder_path_async( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_response()) + + await service.run_async(RULE, {}) + + folders_service.retrieve_folder_key_async.assert_awaited_once_with( + "Finance/Loans" + ) + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + def test_env_folder_key_wins_over_env_folder_path( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}) + + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" + + def test_deployed_requires_a_folder( + self, httpx_mock: HTTPXMock, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="deployed business rule"): + service.run(RULE, {}) + + assert httpx_mock.get_requests() == [] + + def test_deployed_rejects_a_blank_folder_key( + self, httpx_mock: HTTPXMock, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="deployed business rule"): + service.run(RULE, {}, folder_key=" ") + + assert httpx_mock.get_requests() == [] + + def test_sends_no_organization_unit_id( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert "x-uipath-organizationunitid" not in request.headers + + def test_rejects_both_folder_key_and_path( + self, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="Only one of"): + service.run(RULE, {}, folder_key=FOLDER_KEY, folder_path="Finance") + + +class TestDeployed: + def test_sends_single_input_and_maps_decisions( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + method="POST", + json=_response( + _one_decision(Grade="B", Rate=3.5), + businessRuleName="Loan Pricing", + version="1.0.3", + ), + ) + + result = service.run( + "Loan Pricing", + {"creditScore": 740}, + version="1.0.3", + decision_names=["RiskGrade"], + folder_key=FOLDER_KEY, + ) + + assert result.status == BusinessRuleStatus.SUCCESS + assert result.decisions[0].decision_name == "RiskGrade" + assert result.decisions[0].outputs == {"Grade": "B", "Rate": 3.5} + assert result.errors == [] + assert result.top_level_error is None + assert result.business_rule_name == "Loan Pricing" + assert result.version == "1.0.3" + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content) == { + "businessRuleName": "Loan Pricing", + "version": "1.0.3", + "decisionNames": ["RiskGrade"], + "input": {"creditScore": 740}, + } + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert request.headers["Authorization"] == "Bearer secret" + assert ( + request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.BusinessRulesService.run/{version}" + ) + + def test_omits_optional_fields( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, version=" ", folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body == {"businessRuleName": RULE, "input": {}} + + def test_sends_no_explain_or_batch( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {"a": 1}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert "explain" not in body + assert "inputs" not in body + + async def test_run_async_resolves_folder_path( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response(_one_decision(x=1))) + + result = await service.run_async(RULE, {"a": 1}, folder_path="Finance") + + folders_service.retrieve_folder_key_async.assert_awaited_once_with("Finance") + assert result.status == BusinessRuleStatus.SUCCESS + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + +class TestCaller: + def test_defaults_from_the_environment( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_PROCESS_UUID", "release-key") + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + monkeypatch.setenv("UIPATH_FOLDER_KEY", "caller-folder-key") + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == { + "resourceKey": "release-key", + "runKey": JOB_KEY, + "folderKey": "caller-folder-key", + } + # The rule's folder, not the caller's, scopes the run. + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + def test_explicit_fields_win_over_the_environment( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_PROCESS_UUID", "release-key") + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run( + RULE, + {}, + folder_key=FOLDER_KEY, + caller=BusinessRuleCaller(run_key="agent-run", folder_key="agent-folder"), + ) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == { + "resourceKey": "release-key", + "runKey": "agent-run", + "folderKey": "agent-folder", + } + + def test_blank_fields_are_left_out( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run( + RULE, + {}, + folder_key=FOLDER_KEY, + caller=BusinessRuleCaller(resource_key="release-key", run_key=" "), + ) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["caller"] == {"resourceKey": "release-key"} + + def test_no_caller_without_any_value( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert "caller" not in json.loads(request.content) + + +class TestResult: + def test_partial_success_on_207( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=207, + json=_response( + { + "decisions": [ + {"decisionName": "RiskGrade", "outputs": {"Grade": "B"}}, + { + "decisionName": "Payment", + "error": { + "code": "DECISION_FAILED", + "message": "null arithmetic", + }, + }, + ], + } + ), + ) + + result = service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert result.status == BusinessRuleStatus.PARTIAL_SUCCESS + assert result.decisions[1].error is not None + assert result.decisions[1].error.code == "DECISION_FAILED" + + def test_every_decision_failing_is_all_failed( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=207, + json=_response( + { + "decisions": [ + { + "decisionName": "Payment", + "error": {"code": "DECISION_FAILED", "message": "x"}, + } + ] + } + ), + ) + + result = service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert result.status == BusinessRuleStatus.ALL_FAILED + + def test_input_level_error_is_all_failed( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=207, + json=_response( + { + "errors": [ + { + "code": "INPUT_VALIDATION_FAILED", + "message": "creditScore must be a number", + } + ] + } + ), + ) + + result = service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert result.status == BusinessRuleStatus.ALL_FAILED + assert result.errors[0].code == "INPUT_VALIDATION_FAILED" + + def test_carries_the_top_level_error( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=207, + json=_response( + {"errors": [{"code": "INPUT_FAILED", "message": "x"}]}, + error={"code": "UPSTREAM_ERROR", "message": "y"}, + ), + ) + + result = service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert result.top_level_error == "UPSTREAM_ERROR" + + def test_raises_when_the_result_is_missing( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, json={"meta": {"timestamp": "2026-09-28T00:00:00Z"}} + ) + + with pytest.raises(ValueError, match="did not include a result"): + service.run(RULE, {}, folder_key=FOLDER_KEY) + + def test_raises_enriched_exception_on_error_envelope( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=404, + json={ + "businessRuleName": "Missing", + "error": {"code": "RULE_NOT_FOUND", "message": "no such rule"}, + "meta": {"timestamp": "2026-09-28T00:00:00Z"}, + }, + ) + + with pytest.raises(EnrichedException) as exc: + service.run("Missing", {}, folder_key=FOLDER_KEY) + + assert exc.value.status_code == 404 + + +EXPLICIT_TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736" +EXPLICIT_SPAN_ID = "00f067aa0ba902b7" +AMBIENT_TRACE_ID = "0af7651916cd43dd8448eb211c80319c" +AMBIENT_SPAN_ID = "b7ad6b7169203331" + + +@pytest.fixture +def ambient_span() -> Iterator[None]: + span = NonRecordingSpan( + SpanContext( + trace_id=int(AMBIENT_TRACE_ID, 16), + span_id=int(AMBIENT_SPAN_ID, 16), + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + ) + ) + with trace.use_span(span): + yield + + +class TestTraceContext: + def test_explicit_trace_context_wins_over_ambient_span( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + def test_explicit_trace_context_wins_over_uipath_trace_id( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_TRACE_ID", AMBIENT_TRACE_ID) + httpx_mock.add_response(url=evaluate_url, json=_response()) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + def test_ambient_trace_is_used_without_trace_context( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers[TRACEPARENT].startswith(f"00-{AMBIENT_TRACE_ID}-") + + def test_override_does_not_leak_into_the_next_call( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response(url=evaluate_url, json=_response()) + httpx_mock.add_response(url=evaluate_url, json=_response()) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + service.run(RULE, {}, folder_key=FOLDER_KEY) + + first, second = httpx_mock.get_requests() + assert EXPLICIT_TRACE_ID in first.headers[TRACEPARENT] + assert second.headers[TRACEPARENT].startswith(f"00-{AMBIENT_TRACE_ID}-") + + async def test_explicit_trace_context_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + await service.run_async(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + def test_explicit_trace_context_survives_a_retry( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, status_code=503) + httpx_mock.add_response(url=evaluate_url, json=_response()) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + + first, second = httpx_mock.get_requests() + expected = f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + assert first.headers[TRACEPARENT] == expected + assert second.headers[TRACEPARENT] == expected + + def test_trace_id_accepts_uuid_form_and_upper_case(self) -> None: + context = TraceContext( + trace_id="4BF92F35-77B3-4DA6-A3CE-929D0E0E4736", + parent_span_id="00F067AA0BA902B7", + ) + + assert context.to_traceparent() == ( + f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + @pytest.mark.parametrize( + ("trace_id", "parent_span_id", "message"), + [ + ("abc", EXPLICIT_SPAN_ID, "trace_id must be 32 hex"), + ("z" * 32, EXPLICIT_SPAN_ID, "trace_id must be 32 hex"), + ("0" * 32, EXPLICIT_SPAN_ID, "trace_id must not be all zeros"), + (EXPLICIT_TRACE_ID, "abc", "parent_span_id must be 16 hex"), + (EXPLICIT_TRACE_ID, "0" * 16, "parent_span_id must not be all zeros"), + ], + ) + def test_rejects_malformed_ids( + self, trace_id: str, parent_span_id: str, message: str + ) -> None: + with pytest.raises(ValidationError, match=message): + TraceContext(trace_id=trace_id, parent_span_id=parent_span_id) + + +@pytest.fixture +def rule_override() -> Iterator[None]: + overwrite = GenericResourceOverwrite( + resource_type="businessRule", + name="Loan Pricing EU", + folder_path="Finance/EU", + ) + token = _resource_overwrites.set({"businessRule.Loan Pricing": overwrite}) + try: + yield + finally: + _resource_overwrites.reset(token) + + +class TestResourceOverride: + def test_parser_accepts_business_rule_bindings(self) -> None: + overwrite = ResourceOverwriteParser.parse( + "businessRule.Loan Pricing", + {"name": "Loan Pricing EU", "folderPath": "Finance/EU"}, + ) + + assert overwrite.resource_identifier == "Loan Pricing EU" + assert overwrite.folder_identifier == "Finance/EU" + + def test_override_replaces_rule_name_and_folder( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, folder_path="Finance") + + folders_service.retrieve_folder_key.assert_called_once_with("Finance/EU") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + async def test_override_applies_to_run_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + await service.run_async(RULE, {}, folder_path="Finance") + + folders_service.retrieve_folder_key_async.assert_awaited_once_with("Finance/EU") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + + def test_override_folder_replaces_callers_folder_key( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, folder_key="callers-folder-key") + + folders_service.retrieve_folder_key.assert_called_once_with("Finance/EU") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + async def test_override_folder_replaces_callers_folder_key_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + await service.run_async(RULE, {}, folder_key="callers-folder-key") + + folders_service.retrieve_folder_key_async.assert_awaited_once_with("Finance/EU") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + def test_callers_folder_key_kept_without_a_matching_override( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run("Risk Tier", {}, folder_key="callers-folder-key") + + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == "callers-folder-key" + + def test_other_rules_are_not_overridden( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run("Risk Tier", {}, folder_path="Finance") + + folders_service.retrieve_folder_key.assert_called_once_with("Finance") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Risk Tier" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 8d7cccdcc..b02fd2c40 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.34" +version = "0.2.35" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 9a1aa77c6..72e4bda1b 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.34" +version = "0.2.35" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },