Skip to content

Commit fbcd73f

Browse files
authored
Merge pull request #696 from UiPath/akshaya/connections_metadata
feat(ConnectionsMetadata): retrieve output schema from connections API
2 parents a8fe199 + 087b9a9 commit fbcd73f

File tree

7 files changed

+530
-4
lines changed

7 files changed

+530
-4
lines changed

src/uipath/_services/connections_service.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from .._config import Config
88
from .._execution_context import ExecutionContext
99
from .._utils import Endpoint, RequestSpec, header_folder, infer_bindings
10-
from ..models import Connection, ConnectionToken, EventArguments
10+
from ..models import Connection, ConnectionMetadata, ConnectionToken, EventArguments
1111
from ..models.connections import ConnectionTokenType
1212
from ..tracing._traced import traced
1313
from ._base_service import BaseService
@@ -54,6 +54,31 @@ def retrieve(self, key: str) -> Connection:
5454
response = self.request(spec.method, url=spec.endpoint)
5555
return Connection.model_validate(response.json())
5656

57+
@traced(
58+
name="connections_metadata",
59+
run_type="uipath",
60+
hide_output=True,
61+
)
62+
def metadata(
63+
self, element_instance_id: int, tool_path: str, schema_mode: bool = True
64+
) -> ConnectionMetadata:
65+
"""Synchronously retrieve connection API metadata.
66+
67+
This method fetches the metadata for a connection,
68+
which can be used to establish communication with an external service.
69+
70+
Args:
71+
element_instance_id (int): The element instance ID of the connection.
72+
tool_path (str): The tool path to retrieve metadata for.
73+
schema_mode (bool): Whether or not to represent the output schema in the response fields.
74+
75+
Returns:
76+
ConnectionMetadata: The connection metadata.
77+
"""
78+
spec = self._metadata_spec(element_instance_id, tool_path, schema_mode)
79+
response = self.request(spec.method, url=spec.endpoint, headers=spec.headers)
80+
return ConnectionMetadata.model_validate(response.json())
81+
5782
@traced(name="connections_list", run_type="uipath")
5883
def list(
5984
self,
@@ -186,6 +211,33 @@ async def retrieve_async(self, key: str) -> Connection:
186211
response = await self.request_async(spec.method, url=spec.endpoint)
187212
return Connection.model_validate(response.json())
188213

214+
@traced(
215+
name="connections_metadata",
216+
run_type="uipath",
217+
hide_output=True,
218+
)
219+
async def metadata_async(
220+
self, element_instance_id: int, tool_path: str, schema_mode: bool = True
221+
) -> ConnectionMetadata:
222+
"""Asynchronously retrieve connection API metadata.
223+
224+
This method fetches the metadata for a connection,
225+
which can be used to establish communication with an external service.
226+
227+
Args:
228+
element_instance_id (int): The element instance ID of the connection.
229+
tool_path (str): The tool path to retrieve metadata for.
230+
schema_mode (bool): Whether or not to represent the output schema in the response fields.
231+
232+
Returns:
233+
ConnectionMetadata: The connection metadata.
234+
"""
235+
spec = self._metadata_spec(element_instance_id, tool_path, schema_mode)
236+
response = await self.request_async(
237+
spec.method, url=spec.endpoint, headers=spec.headers
238+
)
239+
return ConnectionMetadata.model_validate(response.json())
240+
189241
@traced(
190242
name="connections_retrieve_token",
191243
run_type="uipath",
@@ -324,6 +376,20 @@ def _retrieve_spec(self, key: str) -> RequestSpec:
324376
endpoint=Endpoint(f"/connections_/api/v1/Connections/{key}"),
325377
)
326378

379+
def _metadata_spec(
380+
self, element_instance_id: int, tool_path: str, schema_mode: bool
381+
) -> RequestSpec:
382+
metadata_endpoint_url = f"/elements_/v3/element/instances/{element_instance_id}/elements/{tool_path}/metadata"
383+
return RequestSpec(
384+
method="GET",
385+
endpoint=Endpoint(metadata_endpoint_url),
386+
headers={
387+
"accept": "application/schema+json"
388+
if schema_mode
389+
else "application/json"
390+
},
391+
)
392+
327393
def _retrieve_token_spec(
328394
self, key: str, token_type: ConnectionTokenType = ConnectionTokenType.DIRECT
329395
) -> RequestSpec:

src/uipath/models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from .assets import Asset, UserAsset
44
from .attachment import Attachment
55
from .buckets import Bucket
6-
from .connections import Connection, ConnectionToken, EventArguments
6+
from .connections import Connection, ConnectionMetadata, ConnectionToken, EventArguments
77
from .context_grounding import ContextGroundingQueryResponse
88
from .context_grounding_index import ContextGroundingIndex
99
from .errors import BaseUrlMissingError, SecretMissingError
@@ -38,6 +38,7 @@
3838
"QueueItemPriority",
3939
"TransactionItemResult",
4040
"Connection",
41+
"ConnectionMetadata",
4142
"ConnectionToken",
4243
"EventArguments",
4344
"Job",

src/uipath/models/connections.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44
from pydantic import BaseModel, ConfigDict, Field
55

66

7+
class ConnectionMetadata(BaseModel):
8+
"""Metadata about a connection."""
9+
10+
fields: dict[str, Any] = Field(default_factory=dict, alias="fields")
11+
12+
model_config = ConfigDict(populate_by_name=True, extra="allow")
13+
14+
715
class Connection(BaseModel):
816
model_config = ConfigDict(
917
validate_by_name=True,

src/uipath/utils/dynamic_schema.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Json schema to dynamic pydantic model."""
2+
3+
from typing import Any, Dict, List, Optional, Type, Union
4+
5+
from pydantic import BaseModel, Field, create_model
6+
7+
8+
def jsonschema_to_pydantic(
9+
schema: dict[str, Any],
10+
definitions: Optional[dict[str, Any]] = None,
11+
) -> Type[BaseModel]:
12+
"""Convert a schema dict to a pydantic model.
13+
14+
Modified version of https://github.com/kreneskyp/jsonschema-pydantic to account for two unresolved issues.
15+
16+
Args:
17+
schema: JSON schema.
18+
definitions: Definitions dict. Defaults to `$def`.
19+
20+
Returns: Pydantic model.
21+
"""
22+
title = schema.get("title", "DynamicModel")
23+
assert isinstance(title, str), "Title of a model must be a string."
24+
25+
description = schema.get("description", None)
26+
27+
# top level schema provides definitions
28+
if definitions is None:
29+
if "$defs" in schema:
30+
definitions = schema["$defs"]
31+
elif "definitions" in schema:
32+
definitions = schema["definitions"]
33+
else:
34+
definitions = {}
35+
36+
def convert_type(prop: dict[str, Any]) -> Any:
37+
if "$ref" in prop:
38+
ref_path = prop["$ref"].split("/")
39+
ref = definitions[ref_path[-1]]
40+
return jsonschema_to_pydantic(ref, definitions)
41+
42+
if "type" in prop:
43+
type_mapping = {
44+
"string": str,
45+
"number": float,
46+
"integer": int,
47+
"boolean": bool,
48+
"array": List,
49+
"object": Dict[str, Any],
50+
"null": None,
51+
}
52+
53+
type_ = prop["type"]
54+
55+
if type_ == "array":
56+
item_type: Any = convert_type(prop.get("items", {}))
57+
assert isinstance(item_type, type)
58+
return List[item_type] # noqa F821
59+
elif type_ == "object":
60+
if "properties" in prop:
61+
return jsonschema_to_pydantic(prop, definitions)
62+
else:
63+
return Dict[str, Any]
64+
else:
65+
return type_mapping.get(type_, Any)
66+
67+
elif "allOf" in prop:
68+
combined_fields = {}
69+
for sub_schema in prop["allOf"]:
70+
model = jsonschema_to_pydantic(sub_schema, definitions)
71+
combined_fields.update(model.__annotations__)
72+
return create_model("CombinedModel", **combined_fields)
73+
74+
elif "anyOf" in prop:
75+
unioned_types = tuple(
76+
convert_type(sub_schema) for sub_schema in prop["anyOf"]
77+
)
78+
return Union[unioned_types]
79+
elif prop == {} or "type" not in prop:
80+
return Any
81+
else:
82+
raise ValueError(f"Unsupported schema: {prop}")
83+
84+
fields: dict[str, Any] = {}
85+
required_fields = schema.get("required", [])
86+
87+
for name, prop in schema.get("properties", {}).items():
88+
pydantic_type = convert_type(prop)
89+
field_kwargs = {}
90+
if "default" in prop:
91+
field_kwargs["default"] = prop["default"]
92+
if name not in required_fields:
93+
# Note that we do not make this optional. This is due to a limitation in Pydantic/Python.
94+
# If we convert the Optional type back to json schema, it is represented as type | None.
95+
# pydantic_type = Optional[pydantic_type]
96+
97+
if "default" not in field_kwargs:
98+
field_kwargs["default"] = None
99+
if "description" in prop:
100+
field_kwargs["description"] = prop["description"]
101+
if "title" in prop:
102+
field_kwargs["title"] = prop["title"]
103+
104+
fields[name] = (pydantic_type, Field(**field_kwargs))
105+
106+
convert_type(schema.get("properties", {}).get("choices", {}))
107+
108+
model = create_model(title, **fields)
109+
if description:
110+
model.__doc__ = description
111+
return model
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from typing import List, Optional
2+
3+
from pydantic import BaseModel, Field
4+
5+
from uipath.utils.dynamic_schema import jsonschema_to_pydantic
6+
7+
8+
def test_dynamic_schema():
9+
# Arrange
10+
class InnerSchema(BaseModel):
11+
"""Inner schema description."""
12+
13+
pass
14+
15+
class Schema(BaseModel):
16+
"""Schema description."""
17+
18+
string: str = Field(
19+
default="", title="String Title", description="String Description"
20+
)
21+
optional_string: Optional[str] = Field(
22+
default=None,
23+
title="Optional String Title",
24+
description="Optional String Description",
25+
)
26+
list_str: List[str] = Field(
27+
default=[], title="List String", description="List String Description"
28+
)
29+
30+
integer: int = Field(
31+
default=0, title="Integer Title", description="Integer Description"
32+
)
33+
optional_integer: Optional[int] = Field(
34+
default=None,
35+
title="Option Integer Title",
36+
description="Option Integer Description",
37+
)
38+
list_integer: List[int] = Field(
39+
default=[],
40+
title="List Integer Title",
41+
description="List Integer Description",
42+
)
43+
44+
floating: float = Field(
45+
default=0.0, title="Floating Title", description="Floating Description"
46+
)
47+
optional_floating: Optional[float] = Field(
48+
default=None,
49+
title="Option Floating Title",
50+
description="Option Floating Description",
51+
)
52+
list_floating: List[float] = Field(
53+
default=[],
54+
title="List Floating Title",
55+
description="List Floating Description",
56+
)
57+
58+
boolean: bool = Field(
59+
default=False, title="Boolean Title", description="Boolean Description"
60+
)
61+
optional_boolean: Optional[bool] = Field(
62+
default=None,
63+
title="Option Boolean Title",
64+
description="Option Boolean Description",
65+
)
66+
list_boolean: List[bool] = Field(
67+
default=[],
68+
title="List Boolean Title",
69+
description="List Boolean Description",
70+
)
71+
72+
nested_object: InnerSchema = Field(
73+
default=InnerSchema(),
74+
title="Nested Object Title",
75+
description="Nested Object Description",
76+
)
77+
optional_nested_object: Optional[InnerSchema] = Field(
78+
default=None,
79+
title="Optional Nested Object Title",
80+
description="Optional Nested Object Description",
81+
)
82+
list_nested_object: List[InnerSchema] = Field(
83+
default=[],
84+
title="List Nested Object Title",
85+
description="List Nested Object Description",
86+
)
87+
88+
schema_json = Schema.model_json_schema()
89+
90+
# Act
91+
dynamic_schema = jsonschema_to_pydantic(schema_json)
92+
dynamic_schema_json = dynamic_schema.model_json_schema()
93+
94+
# Assert
95+
assert dynamic_schema_json == schema_json

0 commit comments

Comments
 (0)