From 262c262f529a8e167d3f354a61d063024439e9b2 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Fri, 20 Mar 2026 17:19:56 +0000 Subject: [PATCH] Fix SDK failing to deserialize NestedEdged GraphQL wrapper types When processing custom GraphQL query responses (e.g. in transforms), the SDK used the outer __typename (NestedEdgedBuiltinIPPrefix) for schema lookup instead of stripping the wrapper prefix to get the actual schema kind (BuiltinIPPrefix). Add strip_graphql_wrapper_prefix() to handle all GraphQL wrapper type prefixes (NestedEdged, Edged, NestedPaginated, Paginated, Related) and apply it in from_graphql() and RelatedNodeBase.__init__. Fixes #881 Co-Authored-By: Claude Opus 4.6 (1M context) --- changelog/881.fixed.md | 1 + infrahub_sdk/node/constants.py | 12 ++ infrahub_sdk/node/node.py | 3 + infrahub_sdk/node/related_node.py | 7 +- tests/unit/sdk/test_node.py | 193 +++++++++++++++++++++++++++++- 5 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 changelog/881.fixed.md diff --git a/changelog/881.fixed.md b/changelog/881.fixed.md new file mode 100644 index 00000000..0b48bbc4 --- /dev/null +++ b/changelog/881.fixed.md @@ -0,0 +1 @@ +Fix SDK failing to deserialize GraphQL responses containing NestedEdged wrapper types for cardinality-one relationships in custom queries and transforms. diff --git a/infrahub_sdk/node/constants.py b/infrahub_sdk/node/constants.py index 7a0bc6fd..34202bfb 100644 --- a/infrahub_sdk/node/constants.py +++ b/infrahub_sdk/node/constants.py @@ -35,3 +35,15 @@ HFID_STR_SEPARATOR = "__" PROFILE_KIND_PREFIX = "Profile" + +# GraphQL wrapper type prefixes that need to be stripped to get the actual schema kind. +# Ordered longest-first so that "NestedEdged" is checked before "Edged", etc. +GRAPHQL_WRAPPER_TYPE_PREFIXES = ("NestedEdged", "NestedPaginated", "Edged", "Paginated", "Related") + + +def strip_graphql_wrapper_prefix(typename: str) -> str: + """Strip GraphQL wrapper type prefixes (NestedEdged, Edged, etc.) to get the actual schema kind.""" + for prefix in GRAPHQL_WRAPPER_TYPE_PREFIXES: + if typename.startswith(prefix): + return typename[len(prefix) :] + return typename diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index a47209dc..d7258454 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -25,6 +25,7 @@ ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE, FILE_DOWNLOAD_FEATURE_NOT_SUPPORTED_MESSAGE, PROPERTIES_OBJECT, + strip_graphql_wrapper_prefix, ) from .metadata import NodeMetadata from .related_node import RelatedNode, RelatedNodeBase, RelatedNodeSync @@ -618,6 +619,7 @@ async def from_graphql( node_kind = data.get("__typename") or data.get("node", {}).get("__typename", None) if not node_kind: raise ValueError("Unable to determine the type of the node, __typename not present in data") + node_kind = strip_graphql_wrapper_prefix(node_kind) schema = await client.schema.get(kind=node_kind, branch=branch, timeout=timeout) return cls(client=client, schema=schema, branch=branch, data=cls._strip_alias(data)) @@ -1501,6 +1503,7 @@ def from_graphql( node_kind = data.get("__typename") or data.get("node", {}).get("__typename", None) if not node_kind: raise ValueError("Unable to determine the type of the node, __typename not present in data") + node_kind = strip_graphql_wrapper_prefix(node_kind) schema = client.schema.get(kind=node_kind, branch=branch, timeout=timeout) return cls(client=client, schema=schema, branch=branch, data=cls._strip_alias(data)) diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 5b46a8f7..5a3fd30e 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -5,7 +5,7 @@ from ..exceptions import Error from ..protocols_base import CoreNodeBase -from .constants import PROFILE_KIND_PREFIX, PROPERTIES_FLAG, PROPERTIES_OBJECT +from .constants import PROFILE_KIND_PREFIX, PROPERTIES_FLAG, PROPERTIES_OBJECT, strip_graphql_wrapper_prefix from .metadata import NodeMetadata, RelationshipMetadata if TYPE_CHECKING: @@ -69,9 +69,8 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self.updated_at: str | None = data.get("updated_at", properties_data.get("updated_at", None)) - # FIXME, we won't need that once we are only supporting paginated results - if self._typename and self._typename.startswith("Related"): - self._typename = self._typename[7:] + if self._typename: + self._typename = strip_graphql_wrapper_prefix(self._typename) for prop in self._properties: prop_data = properties_data.get(prop, properties_data.get(f"_relation__{prop}", None)) diff --git a/tests/unit/sdk/test_node.py b/tests/unit/sdk/test_node.py index ad3d77eb..d7e3b63d 100644 --- a/tests/unit/sdk/test_node.py +++ b/tests/unit/sdk/test_node.py @@ -19,7 +19,7 @@ RelationshipManagerBase, parse_human_friendly_id, ) -from infrahub_sdk.node.constants import SAFE_VALUE +from infrahub_sdk.node.constants import SAFE_VALUE, strip_graphql_wrapper_prefix from infrahub_sdk.node.metadata import NodeMetadata, RelationshipMetadata from infrahub_sdk.node.property import NodeProperty from infrahub_sdk.node.related_node import RelatedNode, RelatedNodeSync @@ -3243,3 +3243,194 @@ async def test_node_generate_input_data_with_file( assert "file" not in input_data["data"]["data"], "file should not be inside nested data dict" assert "file" in input_data["mutation_variables"] assert input_data["mutation_variables"]["file"] is bytes + + +@pytest.mark.parametrize( + ("typename", "expected"), + [ + ("NestedEdgedBuiltinIPPrefix", "BuiltinIPPrefix"), + ("NestedEdgedTestPerson", "TestPerson"), + ("EdgedBuiltinTag", "BuiltinTag"), + ("NestedPaginatedCorePerson", "CorePerson"), + ("PaginatedCoreNode", "CoreNode"), + ("RelatedTestPerson", "TestPerson"), + ("IpamIPPrefix", "IpamIPPrefix"), + ("CoreNode", "CoreNode"), + ], +) +def test_strip_graphql_wrapper_prefix(typename: str, expected: str) -> None: + assert strip_graphql_wrapper_prefix(typename) == expected + + +@pytest.mark.parametrize("client_type", client_types) +async def test_from_graphql_with_nested_edged_typename( + clients: BothClients, + client_type: str, +) -> None: + """Test that from_graphql correctly strips NestedEdged prefix from __typename. + + When a cardinality-one relationship is queried via a custom GraphQL query, + the response includes a NestedEdged wrapper type. The SDK must strip this + prefix to find the actual schema kind for node deserialization. + """ + ip_prefix_schema = { + "name": "IPPrefix", + "namespace": "Builtin", + "attributes": [{"name": "prefix", "kind": "IPNetwork"}], + "relationships": [], + } + ip_address_schema = { + "name": "IPAddress", + "namespace": "Ipam", + "attributes": [{"name": "address", "kind": "IPHost"}], + "relationships": [ + { + "name": "ip_prefix", + "peer": "BuiltinIPPrefix", + "cardinality": "one", + "optional": True, + } + ], + } + schema_data = { + "version": "1.0", + "nodes": [ip_prefix_schema, ip_address_schema], + } + + # Data as returned by GraphQL for a cardinality-one relationship (NestedEdged wrapper) + nested_edged_data = { + "__typename": "NestedEdgedBuiltinIPPrefix", + "node": { + "__typename": "BuiltinIPPrefix", + "id": "prefix-1", + "display_label": "10.0.0.0/24", + "prefix": {"value": "10.0.0.0/24"}, + }, + "properties": { + "is_protected": False, + "source": None, + "owner": None, + "updated_at": None, + }, + } + + if client_type == "standard": + clients.standard.schema.set_cache(schema_data, branch="main") + node = await InfrahubNode.from_graphql( + client=clients.standard, + branch="main", + data=nested_edged_data, + ) + else: + clients.sync.schema.set_cache(schema_data, branch="main") + node = InfrahubNodeSync.from_graphql( + client=clients.sync, + branch="main", + data=nested_edged_data, + ) + + assert node.id == "prefix-1" + assert node.typename == "BuiltinIPPrefix" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_process_relationships_with_nested_edged_cardinality_one( + clients: BothClients, + client_type: str, +) -> None: + """Test that _process_relationships handles NestedEdged data for cardinality-one relationships. + + This reproduces the bug where querying ip_prefix (cardinality=one) on IpamIPAddress + returns a NestedEdged wrapper type that the SDK failed to resolve. + """ + ip_prefix_schema = { + "name": "IPPrefix", + "namespace": "Builtin", + "attributes": [{"name": "prefix", "kind": "IPNetwork"}], + "relationships": [], + } + ip_address_schema = { + "name": "IPAddress", + "namespace": "Ipam", + "attributes": [{"name": "address", "kind": "IPHost"}], + "relationships": [ + { + "name": "ip_prefix", + "peer": "BuiltinIPPrefix", + "cardinality": "one", + "optional": True, + } + ], + } + schema_data = { + "version": "1.0", + "nodes": [ip_prefix_schema, ip_address_schema], + } + + # Simulate GraphQL response for an IP address with a NestedEdged ip_prefix + ip_address_data = { + "node": { + "__typename": "IpamIPAddress", + "id": "ip-1", + "display_label": "10.0.0.1/24", + "address": {"value": "10.0.0.1/24"}, + "ip_prefix": { + "__typename": "NestedEdgedBuiltinIPPrefix", + "node": { + "__typename": "BuiltinIPPrefix", + "id": "prefix-1", + "display_label": "10.0.0.0/24", + "prefix": {"value": "10.0.0.0/24"}, + }, + "properties": { + "is_protected": False, + "source": None, + "owner": None, + "updated_at": None, + }, + }, + } + } + + if client_type == "standard": + clients.standard.schema.set_cache(schema_data, branch="main") + ip_schema = await clients.standard.schema.get(kind="IpamIPAddress", branch="main") + node = await InfrahubNode.from_graphql( + client=clients.standard, + schema=ip_schema, + branch="main", + data=ip_address_data, + ) + related_nodes: list[InfrahubNode] = [] + await node._process_relationships( + node_data=ip_address_data, + branch="main", + related_nodes=related_nodes, + recursive=False, + ) + else: + clients.sync.schema.set_cache(schema_data, branch="main") + ip_schema = clients.sync.schema.get(kind="IpamIPAddress", branch="main") + node = InfrahubNodeSync.from_graphql( + client=clients.sync, + schema=ip_schema, + branch="main", + data=ip_address_data, + ) + related_nodes_sync: list[InfrahubNodeSync] = [] + node._process_relationships( + node_data=ip_address_data, + branch="main", + related_nodes=related_nodes_sync, + recursive=False, + ) + related_nodes = related_nodes_sync # type: ignore[assignment] + + # The ip_prefix relationship should be initialized with data from the NestedEdged response + assert node.ip_prefix.initialized is True + assert node.ip_prefix.id == "prefix-1" + + # _process_relationships should have created a related node for the ip_prefix + assert len(related_nodes) == 1 + assert related_nodes[0].id == "prefix-1" + assert related_nodes[0].typename == "BuiltinIPPrefix"