From 70f00123d8d88b3e43dc91d6b356d32eb589765f Mon Sep 17 00:00:00 2001 From: Sooriya04 Date: Sat, 31 Oct 2026 20:38:05 +0530 Subject: [PATCH 1/3] feat(gql): add graphql-to-jsonschema converter and selection set generator - Add schemaconverter to translate GraphQL types to UTCP JsonSchema - Support auto-generation of selection sets and polymorphic union fragments - Dynamically infer variable types and operation types from introspected schema - Add comprehensive offline unit tests and integration tests --- .../gql/src/utcp_gql/gql_call_template.py | 14 +- .../utcp_gql/gql_communication_protocol.py | 114 ++++-- .../gql/src/utcp_gql/schema_converter.py | 324 ++++++++++++++++++ .../gql/tests/test_graphql_integration.py | 53 ++- .../gql/tests/test_schema_converter.py | 250 ++++++++++++++ 5 files changed, 724 insertions(+), 31 deletions(-) create mode 100644 plugins/communication_protocols/gql/src/utcp_gql/schema_converter.py create mode 100644 plugins/communication_protocols/gql/tests/test_schema_converter.py diff --git a/plugins/communication_protocols/gql/src/utcp_gql/gql_call_template.py b/plugins/communication_protocols/gql/src/utcp_gql/gql_call_template.py index 579d691..8af42dd 100644 --- a/plugins/communication_protocols/gql/src/utcp_gql/gql_call_template.py +++ b/plugins/communication_protocols/gql/src/utcp_gql/gql_call_template.py @@ -29,6 +29,10 @@ class GraphQLCallTemplate(CallTemplate): and variable types. Example: 'query GetUser($id: ID!) { user(id: $id) { id name } }' variable_types: Map of variable names to GraphQL types for auto-generated queries. Example: {'id': 'ID!', 'limit': 'Int'}. Defaults to 'String' if not specified. + auto_generate_selection_set: Whether to automatically generate selection sets for + object-returning fields if no custom query is provided. Defaults to True. + selection_depth: Maximum depth of subfields to include when auto-generating + selection sets. Defaults to 2. Example: # Full flexibility with custom query @@ -63,6 +67,14 @@ class GraphQLCallTemplate(CallTemplate): "Example: {'id': 'ID!', 'limit': 'Int', 'active': 'Boolean'}. " "Defaults to 'String' if not specified." ) + auto_generate_selection_set: bool = Field( + default=True, + description="Whether to automatically generate selection sets for object-returning fields if no custom query is provided." + ) + selection_depth: int = Field( + default=2, + description="Maximum depth of subfields to include when auto-generating selection sets." + ) @field_serializer("auth") def serialize_auth(self, auth: Optional[Auth]): @@ -90,4 +102,4 @@ def validate_dict(self, data: dict) -> GraphQLCallTemplate: except Exception as e: raise UtcpSerializerValidationError( f"Invalid GraphQLCallTemplate: {e}\n{traceback.format_exc()}" - ) \ No newline at end of file + ) diff --git a/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py b/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py index e6e41bb..d6d6879 100644 --- a/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py +++ b/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py @@ -16,6 +16,12 @@ from utcp_gql.gql_call_template import GraphQLCallTemplate from utcp_gql._security import ensure_secure_url, safe_request_with_redirects +from utcp_gql.schema_converter import ( + convert_field_args_to_input_schema, + convert_field_output_to_output_schema, + format_graphql_type_string, + generate_default_selection_set, +) if TYPE_CHECKING: from utcp.utcp_client import UtcpClient @@ -151,40 +157,55 @@ async def register_manual( # Queries if hasattr(schema, "query_type") and schema.query_type: + query_call_template = ( + manual_call_template + if manual_call_template.operation_type == "query" + else manual_call_template.model_copy(update={"operation_type": "query"}) + ) for name, field in schema.query_type.fields.items(): tools.append( Tool( name=name, description=getattr(field, "description", "") or "", - inputs=JsonSchema(type="object"), - outputs=JsonSchema(type="object"), - tool_call_template=manual_call_template, + inputs=convert_field_args_to_input_schema(field), + outputs=convert_field_output_to_output_schema(field), + tool_call_template=query_call_template, ) ) # Mutations if hasattr(schema, "mutation_type") and schema.mutation_type: + mut_call_template = ( + manual_call_template + if manual_call_template.operation_type == "mutation" + else manual_call_template.model_copy(update={"operation_type": "mutation"}) + ) for name, field in schema.mutation_type.fields.items(): tools.append( Tool( name=name, description=getattr(field, "description", "") or "", - inputs=JsonSchema(type="object"), - outputs=JsonSchema(type="object"), - tool_call_template=manual_call_template, + inputs=convert_field_args_to_input_schema(field), + outputs=convert_field_output_to_output_schema(field), + tool_call_template=mut_call_template, ) ) # Subscriptions (listed for completeness) if hasattr(schema, "subscription_type") and schema.subscription_type: + sub_call_template = ( + manual_call_template + if manual_call_template.operation_type == "subscription" + else manual_call_template.model_copy(update={"operation_type": "subscription"}) + ) for name, field in schema.subscription_type.fields.items(): tools.append( Tool( name=name, description=getattr(field, "description", "") or "", - inputs=JsonSchema(type="object"), - outputs=JsonSchema(type="object"), - tool_call_template=manual_call_template, + inputs=convert_field_args_to_input_schema(field), + outputs=convert_field_output_to_output_schema(field), + tool_call_template=sub_call_template, ) ) @@ -226,35 +247,78 @@ async def call_tool( headers = await self._prepare_headers(tool_call_template, tool_args) transport = _SecureAIOHTTPTransport(url=tool_call_template.url, headers=headers) async with GqlClient(transport=transport, fetch_schema_from_transport=True) as session: - # Filter out header fields from GraphQL variables; these are sent via HTTP headers + schema = session.client.schema + + # Filter out header fields and selection controls from tool_args header_fields = tool_call_template.header_fields or [] - filtered_args = {k: v for k, v in tool_args.items() if k not in header_fields} + special_fields = {"_fields", "_selection"} + filtered_args = { + k: v for k, v in tool_args.items() + if k not in header_fields and k not in special_fields + } + + base_tool_name = tool_name.split(".", 1)[-1] if "." in tool_name else tool_name + + # Look up field definition from schema for exact variable typing, selection, and operation type + field_def = None + op_type = getattr(tool_call_template, "operation_type", "query") + if schema: + # Detect the real operation type from the schema definition + if hasattr(schema, "mutation_type") and schema.mutation_type and base_tool_name in schema.mutation_type.fields: + op_type = "mutation" + field_def = schema.mutation_type.fields[base_tool_name] + elif hasattr(schema, "subscription_type") and schema.subscription_type and base_tool_name in schema.subscription_type.fields: + op_type = "subscription" + field_def = schema.subscription_type.fields[base_tool_name] + elif hasattr(schema, "query_type") and schema.query_type and base_tool_name in schema.query_type.fields: + op_type = "query" + field_def = schema.query_type.fields[base_tool_name] # Use custom query if provided (highest flexibility for agents) if tool_call_template.query: gql_str = tool_call_template.query else: - # Auto-generate query - use variable_types for proper typing - op_type = getattr(tool_call_template, "operation_type", "query") - base_tool_name = tool_name.split(".", 1)[-1] if "." in tool_name else tool_name variable_types = tool_call_template.variable_types or {} - # Build variable definitions with proper types (default to String) - arg_str = ", ".join( - f"${k}: {variable_types.get(k, 'String')}" - for k in filtered_args.keys() - ) - var_defs = f"({arg_str})" if arg_str else "" + # Determine variable types from schema if available, falling back to template or String + var_type_strs = [] + for k in filtered_args.keys(): + if k in variable_types: + vtype = variable_types[k] + elif field_def and k in field_def.args: + vtype = format_graphql_type_string(field_def.args[k].type) + else: + vtype = "String" + var_type_strs.append(f"${k}: {vtype}") + + var_defs = f"({', '.join(var_type_strs)})" if var_type_strs else "" arg_pass = ", ".join(f"{k}: ${k}" for k in filtered_args.keys()) arg_pass = f"({arg_pass})" if arg_pass else "" - # Note: Auto-generated queries for object-returning fields will still fail - # without a selection set. Use the `query` field for full control. - gql_str = f"{op_type} {var_defs} {{ {base_tool_name}{arg_pass} }}" + # Build selection set for object-returning fields + selection_set = "" + if "_selection" in tool_args: + sel = str(tool_args["_selection"]).strip() + if sel and not sel.startswith("{"): + sel = f"{{ {sel} }}" + selection_set = sel + elif "_fields" in tool_args and isinstance(tool_args["_fields"], list): + selection_set = "{ " + " ".join(tool_args["_fields"]) + " }" + elif getattr(tool_call_template, "auto_generate_selection_set", True) and field_def: + depth = getattr(tool_call_template, "selection_depth", 2) + selection_set = generate_default_selection_set(field_def.type, max_depth=depth) + + selection_clause = f" {selection_set}" if selection_set else "" + gql_str = f"{op_type} {var_defs} {{ {base_tool_name}{arg_pass}{selection_clause} }}" logger.debug(f"Auto-generated GraphQL: {gql_str}") document = gql_query(gql_str) - result = await session.execute(document, variable_values=filtered_args) + try: + from gql import GraphQLRequest + request = GraphQLRequest(document, variable_values=filtered_args) + result = await session.execute(request) + except (ImportError, TypeError): + result = await session.execute(document, variable_values=filtered_args) return result async def call_tool_streaming( @@ -269,4 +333,4 @@ async def call_tool_streaming( yield result async def close(self) -> None: - self._oauth_tokens.clear() \ No newline at end of file + self._oauth_tokens.clear() diff --git a/plugins/communication_protocols/gql/src/utcp_gql/schema_converter.py b/plugins/communication_protocols/gql/src/utcp_gql/schema_converter.py new file mode 100644 index 0000000..f96c8fd --- /dev/null +++ b/plugins/communication_protocols/gql/src/utcp_gql/schema_converter.py @@ -0,0 +1,324 @@ +"""GraphQL to UTCP JSON Schema converter and query generator. + +This module provides conversion utilities that bridge GraphQL schemas and +UTCP tool definitions: +1. Translates GraphQL input arguments and types into standard UTCP JsonSchema objects. +2. Translates GraphQL output types into JSON Schema for tool return values. +3. Automatically generates valid GraphQL selection sets for object return types so + auto-generated queries do not fail with missing selection set errors. +""" + +from typing import Any, Dict, List, Optional, Set +from graphql import ( + GraphQLArgument, + GraphQLField, + GraphQLType, + Undefined, + is_enum_type, + is_input_object_type, + is_interface_type, + is_list_type, + is_non_null_type, + is_object_type, + is_scalar_type, + is_union_type, +) +from utcp.data.tool import JsonSchema, JsonType + + +# Mapping of standard GraphQL scalar names to JSON Schema types +SCALAR_TYPE_MAP: Dict[str, Dict[str, Any]] = { + "String": {"type": "string"}, + "Int": {"type": "integer"}, + "Float": {"type": "number"}, + "Boolean": {"type": "boolean"}, + "ID": {"type": "string", "description": "Unique identifier"}, + "JSON": {"type": "object"}, + "JSONObject": {"type": "object"}, + "DateTime": {"type": "string", "format": "date-time"}, + "Date": {"type": "string", "format": "date"}, + "Time": {"type": "string", "format": "time"}, + "UUID": {"type": "string", "format": "uuid"}, +} + + +def convert_graphql_type_to_json_schema( + gql_type: GraphQLType, + description: Optional[str] = None, + default_value: Optional[JsonType] = None, + visited: Optional[Set[str]] = None, + depth: int = 0, + max_depth: int = 3, +) -> JsonSchema: + """Convert any GraphQL type into an equivalent UTCP JsonSchema. + + Handles NonNull wrappers, Lists, Scalars, Enums, InputObjects, and Objects, + with cycle protection for recursive schemas. + """ + if visited is None: + visited = set() + + # Unwrap NonNull: the outer field/argument container tracks requirement + if is_non_null_type(gql_type): + return convert_graphql_type_to_json_schema( + gql_type.of_type, + description=description, + default_value=default_value, + visited=visited, + depth=depth, + max_depth=max_depth, + ) + + # Lists -> Array schema + if is_list_type(gql_type): + item_schema = convert_graphql_type_to_json_schema( + gql_type.of_type, + visited=visited, + depth=depth + 1, + max_depth=max_depth, + ) + return JsonSchema( + type="array", + items=item_schema, + description=description, + default=default_value, + ) + + # Standard and custom scalars + if is_scalar_type(gql_type): + mapping = SCALAR_TYPE_MAP.get(gql_type.name, {"type": "string"}) + desc = description or mapping.get("description") or getattr(gql_type, "description", None) + return JsonSchema( + type=mapping.get("type", "string"), + format=mapping.get("format"), + description=desc, + default=default_value, + ) + + # Enum types + if is_enum_type(gql_type): + enum_values = list(gql_type.values.keys()) + desc = description or getattr(gql_type, "description", None) + return JsonSchema( + type="string", + enum=enum_values, + description=desc, + default=default_value, + ) + + # Input object types (used in arguments) + if is_input_object_type(gql_type): + type_name = getattr(gql_type, "name", "") + desc = description or getattr(gql_type, "description", None) + + if type_name in visited or depth >= max_depth: + return JsonSchema(type="object", description=desc, default=default_value) + + visited_branch = visited | {type_name} + properties: Dict[str, JsonSchema] = {} + required: List[str] = [] + + for field_name, input_field in gql_type.fields.items(): + field_t = input_field.type + if is_non_null_type(field_t): + required.append(field_name) + + def_val = ( + input_field.default_value + if input_field.default_value is not None + and input_field.default_value is not Undefined + else None + ) + + properties[field_name] = convert_graphql_type_to_json_schema( + field_t, + description=input_field.description, + default_value=def_val, + visited=visited_branch, + depth=depth + 1, + max_depth=max_depth, + ) + + return JsonSchema( + type="object", + properties=properties if properties else None, + required=required if required else None, + description=desc, + default=default_value, + ) + + # Object types & Interface types (used in outputs) + if is_object_type(gql_type) or is_interface_type(gql_type): + type_name = getattr(gql_type, "name", "") + desc = description or getattr(gql_type, "description", None) + + if type_name in visited or depth >= max_depth: + return JsonSchema(type="object", description=desc, default=default_value) + + visited_branch = visited | {type_name} + properties = {} + for field_name, out_field in gql_type.fields.items(): + properties[field_name] = convert_graphql_type_to_json_schema( + out_field.type, + description=out_field.description, + visited=visited_branch, + depth=depth + 1, + max_depth=max_depth, + ) + + return JsonSchema( + type="object", + properties=properties if properties else None, + description=desc, + default=default_value, + ) + + # Union types + if is_union_type(gql_type): + desc = description or getattr(gql_type, "description", None) + return JsonSchema(type="object", description=desc, default=default_value) + + return JsonSchema(type="object", description=description, default=default_value) + + +def convert_field_args_to_input_schema(field: GraphQLField) -> JsonSchema: + """Convert a GraphQLField's arguments into a UTCP input JsonSchema. + + Populates `properties`, marks `GraphQLNonNull` arguments as `required`, + and sets descriptions and default values. + """ + if not field.args: + return JsonSchema(type="object") + + properties: Dict[str, JsonSchema] = {} + required: List[str] = [] + + for arg_name, arg in field.args.items(): + arg_t = arg.type + if is_non_null_type(arg_t): + required.append(arg_name) + + def_val = ( + arg.default_value + if arg.default_value is not None and arg.default_value is not Undefined + else None + ) + + properties[arg_name] = convert_graphql_type_to_json_schema( + arg_t, + description=arg.description, + default_value=def_val, + ) + + return JsonSchema( + type="object", + properties=properties if properties else None, + required=required if required else None, + ) + + +def convert_field_output_to_output_schema(field: GraphQLField) -> JsonSchema: + """Convert a GraphQLField's return type into a UTCP output JsonSchema.""" + return convert_graphql_type_to_json_schema( + field.type, + description=field.description, + ) + + +def format_graphql_type_string(gql_type: GraphQLType) -> str: + """Return the GraphQL syntax string for a type (e.g. 'ID!', '[String]', 'CountryFilterInput').""" + if is_non_null_type(gql_type): + return f"{format_graphql_type_string(gql_type.of_type)}!" + if is_list_type(gql_type): + return f"[{format_graphql_type_string(gql_type.of_type)}]" + if hasattr(gql_type, "name"): + return gql_type.name + return "String" + + +def generate_default_selection_set( + gql_type: GraphQLType, + max_depth: int = 2, + current_depth: int = 0, + visited: Optional[Set[str]] = None, +) -> str: + """Generate a clean GraphQL selection set string for object types. + + Returns an empty string for scalar and enum return types. + For Object types, includes scalar and enum fields, and recurses up to `max_depth`. + """ + if visited is None: + visited = set() + + # Unwrap NonNull and List + actual_type = gql_type + while is_non_null_type(actual_type) or is_list_type(actual_type): + actual_type = actual_type.of_type + + if is_scalar_type(actual_type) or is_enum_type(actual_type): + return "" + + if is_object_type(actual_type) or is_interface_type(actual_type): + type_name = getattr(actual_type, "name", "") + if type_name in visited or current_depth >= max_depth: + # Fall back to an identifier or name if present + for id_field in ("id", "code", "name"): + if id_field in actual_type.fields: + return f"{{ {id_field} }}" + return "" + + visited_branch = visited | {type_name} + selections: List[str] = [] + + for field_name, field in actual_type.fields.items(): + # Skip fields with mandatory arguments that have no default value + if field.args and any( + is_non_null_type(a.type) and (a.default_value is None or a.default_value is Undefined) + for a in field.args.values() + ): + continue + + f_type = field.type + while is_non_null_type(f_type) or is_list_type(f_type): + f_type = f_type.of_type + + if is_scalar_type(f_type) or is_enum_type(f_type): + selections.append(field_name) + elif (is_object_type(f_type) or is_interface_type(f_type)) and current_depth + 1 < max_depth: + sub_sel = generate_default_selection_set( + f_type, + max_depth=max_depth, + current_depth=current_depth + 1, + visited=visited_branch, + ) + if sub_sel: + selections.append(f"{field_name} {sub_sel}") + + if not selections: + # If no scalars were found, pick the first available field + for field_name in actual_type.fields.keys(): + selections.append(field_name) + break + + return f"{{ {' '.join(selections)} }}" if selections else "" + + if is_union_type(actual_type): + type_name = getattr(actual_type, "name", "") + if type_name in visited or current_depth >= max_depth: + return "{ __typename }" + + visited_branch = visited | {type_name} + fragments: List[str] = ["__typename"] + for member_type in actual_type.types: + member_sel = generate_default_selection_set( + member_type, + max_depth=max_depth, + current_depth=current_depth + 1, + visited=visited_branch, + ) + if member_sel: + fragments.append(f"... on {member_type.name} {member_sel}") + + return f"{{ {' '.join(fragments)} }}" + + return "" diff --git a/plugins/communication_protocols/gql/tests/test_graphql_integration.py b/plugins/communication_protocols/gql/tests/test_graphql_integration.py index fdc4fcb..1e3a643 100644 --- a/plugins/communication_protocols/gql/tests/test_graphql_integration.py +++ b/plugins/communication_protocols/gql/tests/test_graphql_integration.py @@ -250,17 +250,15 @@ async def test_error_handling_invalid_query(protocol, client): @pytest.mark.asyncio async def test_error_handling_missing_selection_set_auto_generated(protocol, client): """ - Demonstrate that auto-generated queries fail for object-returning fields. - - This test documents the limitation: without a custom query, object fields fail. - The fix is to always use the `query` field for object-returning operations. + Demonstrate that auto-generated queries fail for object-returning fields when selection generation is disabled. """ - # No custom query - will auto-generate without selection set + # Disable selection set auto-generation template = GraphQLCallTemplate( name="countries_api", url=COUNTRIES_API_URL, operation_type="query", variable_types={"code": "ID!"}, + auto_generate_selection_set=False, ) # This should fail because auto-generated query lacks selection set @@ -273,3 +271,48 @@ async def test_error_handling_missing_selection_set_auto_generated(protocol, cli {"code": "US"}, template, ) + + +@pytest.mark.asyncio +async def test_auto_generated_query_with_selection_set_succeeds(protocol, client): + """Test that auto-generated queries succeed when auto_generate_selection_set is True.""" + template = GraphQLCallTemplate( + name="countries_api", + url=COUNTRIES_API_URL, + operation_type="query", + auto_generate_selection_set=True, + ) + + result = await protocol.call_tool( + client, + "country", + {"code": "US"}, + template, + ) + assert result is not None + assert "country" in result + assert result["country"]["name"] == "United States" + assert result["country"]["code"] == "US" + + +@pytest.mark.asyncio +async def test_call_tool_with_field_selection_override(protocol, client): + """Test specifying explicit _fields in tool_args.""" + template = GraphQLCallTemplate( + name="countries_api", + url=COUNTRIES_API_URL, + ) + + result = await protocol.call_tool( + client, + "country", + {"code": "CA", "_fields": ["name", "capital"]}, + template, + ) + assert result is not None + assert "country" in result + assert result["country"]["name"] == "Canada" + assert result["country"]["capital"] == "Ottawa" + + + diff --git a/plugins/communication_protocols/gql/tests/test_schema_converter.py b/plugins/communication_protocols/gql/tests/test_schema_converter.py new file mode 100644 index 0000000..f306724 --- /dev/null +++ b/plugins/communication_protocols/gql/tests/test_schema_converter.py @@ -0,0 +1,250 @@ +"""Unit tests for the GraphQL to JSON Schema converter and selection set generator. + +These tests run completely offline and mock GraphQL schemas using graphql-core. +""" + +import pytest +from graphql import ( + GraphQLArgument, + GraphQLEnumType, + GraphQLEnumValue, + GraphQLField, + GraphQLFloat, + GraphQLID, + GraphQLInputField, + GraphQLInputObjectType, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLScalarType, + GraphQLString, +) + +from utcp_gql.schema_converter import ( + convert_field_args_to_input_schema, + convert_field_output_to_output_schema, + convert_graphql_type_to_json_schema, + format_graphql_type_string, + generate_default_selection_set, +) + + +def test_convert_scalar_types(): + """Verify standard and custom scalars map to appropriate JSON Schema types.""" + str_schema = convert_graphql_type_to_json_schema(GraphQLString, description="A string") + assert str_schema.type == "string" + assert str_schema.description == "A string" + + int_schema = convert_graphql_type_to_json_schema(GraphQLInt, default_value=42) + assert int_schema.type == "integer" + assert int_schema.default == 42 + + float_schema = convert_graphql_type_to_json_schema(GraphQLFloat) + assert float_schema.type == "number" + + id_schema = convert_graphql_type_to_json_schema(GraphQLID) + assert id_schema.type == "string" + assert "identifier" in (id_schema.description or "").lower() + + # Custom scalar + date_scalar = GraphQLScalarType(name="DateTime", description="ISO timestamp") + dt_schema = convert_graphql_type_to_json_schema(date_scalar) + assert dt_schema.type == "string" + assert dt_schema.format == "date-time" + + +def test_convert_non_null_type(): + """Verify NonNull unwraps to the inner type.""" + non_null_str = GraphQLNonNull(GraphQLString) + schema = convert_graphql_type_to_json_schema(non_null_str) + assert schema.type == "string" + + +def test_convert_list_type(): + """Verify GraphQLList maps to an array schema with items.""" + list_of_ints = GraphQLList(GraphQLInt) + schema = convert_graphql_type_to_json_schema(list_of_ints) + assert schema.type == "array" + assert schema.items is not None + assert schema.items.type == "integer" + + +def test_convert_enum_type(): + """Verify GraphQLEnumType maps to a string with enum values.""" + role_enum = GraphQLEnumType( + "Role", + { + "ADMIN": GraphQLEnumValue(description="Administrator"), + "USER": GraphQLEnumValue(description="Regular user"), + "GUEST": GraphQLEnumValue(description="Guest"), + }, + ) + schema = convert_graphql_type_to_json_schema(role_enum) + assert schema.type == "string" + assert schema.enum == ["ADMIN", "USER", "GUEST"] + + +def test_convert_input_object_with_required_fields(): + """Verify GraphQLInputObjectType maps to an object schema with properties and required fields.""" + user_input = GraphQLInputObjectType( + "UserInput", + { + "username": GraphQLInputField(GraphQLNonNull(GraphQLString), description="User handle"), + "age": GraphQLInputField(GraphQLInt, default_value=18), + "tags": GraphQLInputField(GraphQLList(GraphQLString)), + }, + ) + schema = convert_graphql_type_to_json_schema(user_input) + assert schema.type == "object" + assert schema.properties is not None + assert "username" in schema.properties + assert schema.properties["username"].type == "string" + assert "age" in schema.properties + assert schema.properties["age"].type == "integer" + assert schema.properties["age"].default == 18 + assert "tags" in schema.properties + assert schema.properties["tags"].type == "array" + assert schema.required == ["username"] + + +def test_convert_input_object_cycle_protection(): + """Verify recursive input objects do not trigger infinite recursion.""" + node_input = GraphQLInputObjectType( + "NodeInput", + lambda: { + "name": GraphQLInputField(GraphQLString), + "parent": GraphQLInputField(node_input), + }, + ) + schema = convert_graphql_type_to_json_schema(node_input, max_depth=2) + assert schema.type == "object" + assert schema.properties is not None + assert "name" in schema.properties + assert "parent" in schema.properties + assert schema.properties["parent"].type == "object" + + +def test_convert_field_args_to_input_schema(): + """Verify field arguments are converted to UTCP inputs schema.""" + field = GraphQLField( + GraphQLString, + args={ + "id": GraphQLArgument(GraphQLNonNull(GraphQLID), description="The entity ID"), + "limit": GraphQLArgument(GraphQLInt, default_value=10, description="Max results"), + }, + ) + schema = convert_field_args_to_input_schema(field) + assert schema.type == "object" + assert schema.properties is not None + assert "id" in schema.properties + assert schema.properties["id"].type == "string" + assert schema.properties["id"].description == "The entity ID" + assert "limit" in schema.properties + assert schema.properties["limit"].type == "integer" + assert schema.properties["limit"].default == 10 + assert schema.required == ["id"] + + +def test_convert_field_output_to_output_schema(): + """Verify field return type maps to output schema.""" + person_type = GraphQLObjectType( + "Person", + { + "id": GraphQLField(GraphQLNonNull(GraphQLID)), + "name": GraphQLField(GraphQLString), + }, + ) + field = GraphQLField(GraphQLNonNull(person_type)) + schema = convert_field_output_to_output_schema(field) + assert schema.type == "object" + assert schema.properties is not None + assert "id" in schema.properties + assert "name" in schema.properties + + +def test_format_graphql_type_string(): + """Verify format_graphql_type_string outputs correct GraphQL syntax.""" + assert format_graphql_type_string(GraphQLString) == "String" + assert format_graphql_type_string(GraphQLNonNull(GraphQLString)) == "String!" + assert format_graphql_type_string(GraphQLList(GraphQLInt)) == "[Int]" + assert format_graphql_type_string(GraphQLNonNull(GraphQLList(GraphQLNonNull(GraphQLID)))) == "[ID!]!" + + +def test_generate_default_selection_set_scalar_is_empty(): + """Scalars and enums do not require a selection set.""" + assert generate_default_selection_set(GraphQLString) == "" + assert generate_default_selection_set(GraphQLInt) == "" + assert generate_default_selection_set(GraphQLNonNull(GraphQLString)) == "" + + +def test_generate_default_selection_set_object_includes_scalars_and_nested(): + """Objects generate selection set with scalars and direct sub-relations.""" + Address = GraphQLObjectType( + "Address", + { + "street": GraphQLField(GraphQLString), + "city": GraphQLField(GraphQLString), + }, + ) + Customer = GraphQLObjectType( + "Customer", + { + "id": GraphQLField(GraphQLNonNull(GraphQLID)), + "name": GraphQLField(GraphQLString), + "address": GraphQLField(Address), + }, + ) + + selection = generate_default_selection_set(Customer, max_depth=2) + assert "id" in selection + assert "name" in selection + assert "address { street city }" in selection + + +def test_generate_default_selection_set_union(): + """Unions generate inline fragments with __typename.""" + from graphql import GraphQLUnionType + + Dog = GraphQLObjectType("Dog", {"name": GraphQLField(GraphQLString), "bark": GraphQLField(GraphQLString)}) + Cat = GraphQLObjectType("Cat", {"name": GraphQLField(GraphQLString), "meow": GraphQLField(GraphQLString)}) + Pet = GraphQLUnionType("Pet", types=[Dog, Cat]) + + selection = generate_default_selection_set(Pet, max_depth=2) + assert "__typename" in selection + assert "... on Dog { name bark }" in selection + assert "... on Cat { name meow }" in selection + + +def test_generate_default_selection_set_cycle_protection(): + """Circular references stop at max depth without crashing.""" + User = GraphQLObjectType( + "User", + lambda: { + "id": GraphQLField(GraphQLNonNull(GraphQLID)), + "bestFriend": GraphQLField(User), + }, + ) + selection = generate_default_selection_set(User, max_depth=2) + assert "id" in selection + assert "bestFriend" in selection + + +def test_generate_default_selection_set_skips_required_argument_fields(): + """Fields requiring arguments without defaults must not be auto-selected.""" + Item = GraphQLObjectType( + "Item", + { + "id": GraphQLField(GraphQLNonNull(GraphQLID)), + "name": GraphQLField(GraphQLString), + "parameterizedField": GraphQLField( + GraphQLString, + args={"requiredArg": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ), + }, + ) + selection = generate_default_selection_set(Item) + assert "id" in selection + assert "name" in selection + assert "parameterizedField" not in selection + From 3d056181cc0c506c7e7e4f2c7983bc39ed1282e8 Mon Sep 17 00:00:00 2001 From: Sooriya04 Date: Sat, 31 Oct 2026 21:05:01 +0530 Subject: [PATCH 2/3] fix(gql): resolve 12 review findings on schema converter and query execution - Execution correctness: * Check session.execute parameter compatibility before execution; remove broad try-except fallback to prevent mutations from executing twice on TypeError. * Resolve fields in configured operation_type root first before falling back, preventing query/mutation name collisions. - Selection set generator: * Fall back to { __typename } when depth limits or objects lack scalar fields, preventing invalid bare-field syntax. * Recursively generate inline fragments for union fields nested inside object returns. * Enforce selection_depth >= 1 validation in GraphQLCallTemplate. - Schema converter: * Preserve GraphQL nullability (type=[T, "null"] for nullable, type=T for NonNull). * Build union output schemas using member-specific oneOf schemas. * Support unconstrained types (object, array, string, number, boolean, null) for JSON scalar and reserve object for JSONObject. * Exclude defaulted NonNull arguments and input fields from required list. - Tests: * Add regression tests for duplicate mutations, root precedence, nested unions, nullability, defaulted arguments, JSON scalars, and depth boundaries. * Assert recursion cutoff properties is None in input cycle tests and assert complete bounded selection string in selection cycle tests. --- .../gql/src/utcp_gql/gql_call_template.py | 1 + .../utcp_gql/gql_communication_protocol.py | 44 ++- .../gql/src/utcp_gql/schema_converter.py | 78 +++-- .../gql/tests/test_schema_converter.py | 304 ++++++++++++++++-- 4 files changed, 366 insertions(+), 61 deletions(-) diff --git a/plugins/communication_protocols/gql/src/utcp_gql/gql_call_template.py b/plugins/communication_protocols/gql/src/utcp_gql/gql_call_template.py index 8af42dd..27ad0bd 100644 --- a/plugins/communication_protocols/gql/src/utcp_gql/gql_call_template.py +++ b/plugins/communication_protocols/gql/src/utcp_gql/gql_call_template.py @@ -73,6 +73,7 @@ class GraphQLCallTemplate(CallTemplate): ) selection_depth: int = Field( default=2, + ge=1, description="Maximum depth of subfields to include when auto-generating selection sets." ) diff --git a/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py b/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py index d6d6879..2ba8b9f 100644 --- a/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py +++ b/plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py @@ -1,3 +1,4 @@ +import inspect import logging from typing import Dict, Any, List, Optional, AsyncGenerator, TYPE_CHECKING @@ -263,16 +264,22 @@ async def call_tool( field_def = None op_type = getattr(tool_call_template, "operation_type", "query") if schema: - # Detect the real operation type from the schema definition - if hasattr(schema, "mutation_type") and schema.mutation_type and base_tool_name in schema.mutation_type.fields: - op_type = "mutation" - field_def = schema.mutation_type.fields[base_tool_name] - elif hasattr(schema, "subscription_type") and schema.subscription_type and base_tool_name in schema.subscription_type.fields: - op_type = "subscription" - field_def = schema.subscription_type.fields[base_tool_name] - elif hasattr(schema, "query_type") and schema.query_type and base_tool_name in schema.query_type.fields: - op_type = "query" - field_def = schema.query_type.fields[base_tool_name] + root_types = { + "query": getattr(schema, "query_type", None), + "mutation": getattr(schema, "mutation_type", None), + "subscription": getattr(schema, "subscription_type", None), + } + root_type = root_types.get(op_type) + if root_type and hasattr(root_type, "fields") and base_tool_name in root_type.fields: + field_def = root_type.fields[base_tool_name] + else: + remaining_roots = [k for k in ("mutation", "subscription", "query") if k != op_type] + for candidate_op_type in remaining_roots: + candidate_type = root_types.get(candidate_op_type) + if candidate_type and hasattr(candidate_type, "fields") and base_tool_name in candidate_type.fields: + op_type = candidate_op_type + field_def = candidate_type.fields[base_tool_name] + break # Use custom query if provided (highest flexibility for agents) if tool_call_template.query: @@ -313,11 +320,22 @@ async def call_tool( logger.debug(f"Auto-generated GraphQL: {gql_str}") document = gql_query(gql_str) + request_obj = None try: from gql import GraphQLRequest - request = GraphQLRequest(document, variable_values=filtered_args) - result = await session.execute(request) - except (ImportError, TypeError): + + sig = inspect.signature(session.execute) + params = list(sig.parameters.keys()) + if "document" in params and "request" not in params: + request_obj = None + else: + request_obj = GraphQLRequest(document, variable_values=filtered_args) + except (ImportError, TypeError, ValueError): + request_obj = None + + if request_obj is not None: + result = await session.execute(request_obj) + else: result = await session.execute(document, variable_values=filtered_args) return result diff --git a/plugins/communication_protocols/gql/src/utcp_gql/schema_converter.py b/plugins/communication_protocols/gql/src/utcp_gql/schema_converter.py index f96c8fd..b16fa7b 100644 --- a/plugins/communication_protocols/gql/src/utcp_gql/schema_converter.py +++ b/plugins/communication_protocols/gql/src/utcp_gql/schema_converter.py @@ -8,7 +8,7 @@ auto-generated queries do not fail with missing selection set errors. """ -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Optional, Set, Union from graphql import ( GraphQLArgument, GraphQLField, @@ -33,7 +33,7 @@ "Float": {"type": "number"}, "Boolean": {"type": "boolean"}, "ID": {"type": "string", "description": "Unique identifier"}, - "JSON": {"type": "object"}, + "JSON": {"type": ["object", "array", "string", "number", "boolean", "null"]}, "JSONObject": {"type": "object"}, "DateTime": {"type": "string", "format": "date-time"}, "Date": {"type": "string", "format": "date"}, @@ -42,6 +42,20 @@ } +def _apply_nullability(schema_type: Union[str, List[str]], nullable: bool) -> Union[str, List[str]]: + """Preserve nullability: if nullable, ensure 'null' is included in schema type.""" + if not nullable: + if isinstance(schema_type, list): + non_null = [t for t in schema_type if t != "null"] + return non_null[0] if len(non_null) == 1 else non_null + return schema_type + if isinstance(schema_type, list): + if "null" not in schema_type: + return schema_type + ["null"] + return schema_type + return [schema_type, "null"] + + def convert_graphql_type_to_json_schema( gql_type: GraphQLType, description: Optional[str] = None, @@ -49,16 +63,17 @@ def convert_graphql_type_to_json_schema( visited: Optional[Set[str]] = None, depth: int = 0, max_depth: int = 3, + nullable: bool = True, ) -> JsonSchema: """Convert any GraphQL type into an equivalent UTCP JsonSchema. Handles NonNull wrappers, Lists, Scalars, Enums, InputObjects, and Objects, - with cycle protection for recursive schemas. + with cycle protection for recursive schemas and nullability preservation. """ if visited is None: visited = set() - # Unwrap NonNull: the outer field/argument container tracks requirement + # Unwrap NonNull: inner type is non-nullable if is_non_null_type(gql_type): return convert_graphql_type_to_json_schema( gql_type.of_type, @@ -67,6 +82,7 @@ def convert_graphql_type_to_json_schema( visited=visited, depth=depth, max_depth=max_depth, + nullable=False, ) # Lists -> Array schema @@ -78,7 +94,7 @@ def convert_graphql_type_to_json_schema( max_depth=max_depth, ) return JsonSchema( - type="array", + type=_apply_nullability("array", nullable), items=item_schema, description=description, default=default_value, @@ -88,8 +104,9 @@ def convert_graphql_type_to_json_schema( if is_scalar_type(gql_type): mapping = SCALAR_TYPE_MAP.get(gql_type.name, {"type": "string"}) desc = description or mapping.get("description") or getattr(gql_type, "description", None) + base_type = mapping.get("type", "string") return JsonSchema( - type=mapping.get("type", "string"), + type=_apply_nullability(base_type, nullable), format=mapping.get("format"), description=desc, default=default_value, @@ -100,7 +117,7 @@ def convert_graphql_type_to_json_schema( enum_values = list(gql_type.values.keys()) desc = description or getattr(gql_type, "description", None) return JsonSchema( - type="string", + type=_apply_nullability("string", nullable), enum=enum_values, description=desc, default=default_value, @@ -112,7 +129,7 @@ def convert_graphql_type_to_json_schema( desc = description or getattr(gql_type, "description", None) if type_name in visited or depth >= max_depth: - return JsonSchema(type="object", description=desc, default=default_value) + return JsonSchema(type=_apply_nullability("object", nullable), description=desc, default=default_value) visited_branch = visited | {type_name} properties: Dict[str, JsonSchema] = {} @@ -120,7 +137,7 @@ def convert_graphql_type_to_json_schema( for field_name, input_field in gql_type.fields.items(): field_t = input_field.type - if is_non_null_type(field_t): + if is_non_null_type(field_t) and (input_field.default_value is None or input_field.default_value is Undefined): required.append(field_name) def_val = ( @@ -140,7 +157,7 @@ def convert_graphql_type_to_json_schema( ) return JsonSchema( - type="object", + type=_apply_nullability("object", nullable), properties=properties if properties else None, required=required if required else None, description=desc, @@ -153,7 +170,7 @@ def convert_graphql_type_to_json_schema( desc = description or getattr(gql_type, "description", None) if type_name in visited or depth >= max_depth: - return JsonSchema(type="object", description=desc, default=default_value) + return JsonSchema(type=_apply_nullability("object", nullable), description=desc, default=default_value) visited_branch = visited | {type_name} properties = {} @@ -167,7 +184,7 @@ def convert_graphql_type_to_json_schema( ) return JsonSchema( - type="object", + type=_apply_nullability("object", nullable), properties=properties if properties else None, description=desc, default=default_value, @@ -176,15 +193,31 @@ def convert_graphql_type_to_json_schema( # Union types if is_union_type(gql_type): desc = description or getattr(gql_type, "description", None) - return JsonSchema(type="object", description=desc, default=default_value) + member_schemas = [ + convert_graphql_type_to_json_schema( + member, + visited=visited, + depth=depth + 1, + max_depth=max_depth, + nullable=False, + ) + for member in gql_type.types + ] + if nullable: + member_schemas.append(JsonSchema(type="null")) + return JsonSchema( + description=desc, + default=default_value, + oneOf=member_schemas, # type: ignore[call-arg] + ) - return JsonSchema(type="object", description=description, default=default_value) + return JsonSchema(type=_apply_nullability("object", nullable), description=description, default=default_value) def convert_field_args_to_input_schema(field: GraphQLField) -> JsonSchema: """Convert a GraphQLField's arguments into a UTCP input JsonSchema. - Populates `properties`, marks `GraphQLNonNull` arguments as `required`, + Populates `properties`, marks `GraphQLNonNull` arguments without defaults as `required`, and sets descriptions and default values. """ if not field.args: @@ -195,7 +228,7 @@ def convert_field_args_to_input_schema(field: GraphQLField) -> JsonSchema: for arg_name, arg in field.args.items(): arg_t = arg.type - if is_non_null_type(arg_t): + if is_non_null_type(arg_t) and (arg.default_value is None or arg.default_value is Undefined): required.append(arg_name) def_val = ( @@ -261,11 +294,11 @@ def generate_default_selection_set( if is_object_type(actual_type) or is_interface_type(actual_type): type_name = getattr(actual_type, "name", "") if type_name in visited or current_depth >= max_depth: - # Fall back to an identifier or name if present + # Fall back to an identifier or name if present, or __typename for id_field in ("id", "code", "name"): if id_field in actual_type.fields: return f"{{ {id_field} }}" - return "" + return "{ __typename }" visited_branch = visited | {type_name} selections: List[str] = [] @@ -284,7 +317,7 @@ def generate_default_selection_set( if is_scalar_type(f_type) or is_enum_type(f_type): selections.append(field_name) - elif (is_object_type(f_type) or is_interface_type(f_type)) and current_depth + 1 < max_depth: + elif (is_object_type(f_type) or is_interface_type(f_type) or is_union_type(f_type)) and current_depth + 1 < max_depth: sub_sel = generate_default_selection_set( f_type, max_depth=max_depth, @@ -295,12 +328,9 @@ def generate_default_selection_set( selections.append(f"{field_name} {sub_sel}") if not selections: - # If no scalars were found, pick the first available field - for field_name in actual_type.fields.keys(): - selections.append(field_name) - break + selections.append("__typename") - return f"{{ {' '.join(selections)} }}" if selections else "" + return f"{{ {' '.join(selections)} }}" if is_union_type(actual_type): type_name = getattr(actual_type, "name", "") diff --git a/plugins/communication_protocols/gql/tests/test_schema_converter.py b/plugins/communication_protocols/gql/tests/test_schema_converter.py index f306724..bb8ff14 100644 --- a/plugins/communication_protocols/gql/tests/test_schema_converter.py +++ b/plugins/communication_protocols/gql/tests/test_schema_converter.py @@ -3,7 +3,9 @@ These tests run completely offline and mock GraphQL schemas using graphql-core. """ +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from pydantic import ValidationError from graphql import ( GraphQLArgument, GraphQLEnumType, @@ -18,9 +20,13 @@ GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, + GraphQLSchema, GraphQLString, + GraphQLUnionType, ) +from utcp_gql.gql_call_template import GraphQLCallTemplate +from utcp_gql.gql_communication_protocol import GraphQLCommunicationProtocol from utcp_gql.schema_converter import ( convert_field_args_to_input_schema, convert_field_output_to_output_schema, @@ -31,47 +37,54 @@ def test_convert_scalar_types(): - """Verify standard and custom scalars map to appropriate JSON Schema types.""" + """Verify standard and custom scalars map to appropriate JSON Schema types with nullability.""" str_schema = convert_graphql_type_to_json_schema(GraphQLString, description="A string") - assert str_schema.type == "string" + assert str_schema.type == ["string", "null"] assert str_schema.description == "A string" int_schema = convert_graphql_type_to_json_schema(GraphQLInt, default_value=42) - assert int_schema.type == "integer" + assert int_schema.type == ["integer", "null"] assert int_schema.default == 42 float_schema = convert_graphql_type_to_json_schema(GraphQLFloat) - assert float_schema.type == "number" + assert float_schema.type == ["number", "null"] id_schema = convert_graphql_type_to_json_schema(GraphQLID) - assert id_schema.type == "string" + assert id_schema.type == ["string", "null"] assert "identifier" in (id_schema.description or "").lower() # Custom scalar date_scalar = GraphQLScalarType(name="DateTime", description="ISO timestamp") dt_schema = convert_graphql_type_to_json_schema(date_scalar) - assert dt_schema.type == "string" + assert dt_schema.type == ["string", "null"] assert dt_schema.format == "date-time" def test_convert_non_null_type(): - """Verify NonNull unwraps to the inner type.""" + """Verify NonNull unwraps to the inner type and removes null from type.""" non_null_str = GraphQLNonNull(GraphQLString) schema = convert_graphql_type_to_json_schema(non_null_str) assert schema.type == "string" def test_convert_list_type(): - """Verify GraphQLList maps to an array schema with items.""" + """Verify GraphQLList maps to an array schema with items, preserving nullability.""" list_of_ints = GraphQLList(GraphQLInt) schema = convert_graphql_type_to_json_schema(list_of_ints) - assert schema.type == "array" + assert schema.type == ["array", "null"] assert schema.items is not None - assert schema.items.type == "integer" + assert schema.items.type == ["integer", "null"] + + # NonNull wrapper on list and elements + nn_list = GraphQLNonNull(GraphQLList(GraphQLNonNull(GraphQLInt))) + nn_schema = convert_graphql_type_to_json_schema(nn_list) + assert nn_schema.type == "array" + assert nn_schema.items is not None + assert nn_schema.items.type == "integer" def test_convert_enum_type(): - """Verify GraphQLEnumType maps to a string with enum values.""" + """Verify GraphQLEnumType maps to a string with enum values and nullability.""" role_enum = GraphQLEnumType( "Role", { @@ -81,9 +94,14 @@ def test_convert_enum_type(): }, ) schema = convert_graphql_type_to_json_schema(role_enum) - assert schema.type == "string" + assert schema.type == ["string", "null"] assert schema.enum == ["ADMIN", "USER", "GUEST"] + # Non-null enum + schema_nn = convert_graphql_type_to_json_schema(GraphQLNonNull(role_enum)) + assert schema_nn.type == "string" + assert schema_nn.enum == ["ADMIN", "USER", "GUEST"] + def test_convert_input_object_with_required_fields(): """Verify GraphQLInputObjectType maps to an object schema with properties and required fields.""" @@ -96,20 +114,20 @@ def test_convert_input_object_with_required_fields(): }, ) schema = convert_graphql_type_to_json_schema(user_input) - assert schema.type == "object" + assert schema.type == ["object", "null"] assert schema.properties is not None assert "username" in schema.properties assert schema.properties["username"].type == "string" assert "age" in schema.properties - assert schema.properties["age"].type == "integer" + assert schema.properties["age"].type == ["integer", "null"] assert schema.properties["age"].default == 18 assert "tags" in schema.properties - assert schema.properties["tags"].type == "array" + assert schema.properties["tags"].type == ["array", "null"] assert schema.required == ["username"] def test_convert_input_object_cycle_protection(): - """Verify recursive input objects do not trigger infinite recursion.""" + """Verify recursive input objects do not trigger infinite recursion and cut off properties.""" node_input = GraphQLInputObjectType( "NodeInput", lambda: { @@ -118,11 +136,13 @@ def test_convert_input_object_cycle_protection(): }, ) schema = convert_graphql_type_to_json_schema(node_input, max_depth=2) - assert schema.type == "object" + assert schema.type == ["object", "null"] assert schema.properties is not None assert "name" in schema.properties assert "parent" in schema.properties - assert schema.properties["parent"].type == "object" + assert schema.properties["parent"].type == ["object", "null"] + # Reaching depth limit or revisited node must cut off recursion: revisited parent has no properties + assert schema.properties["parent"].properties is None def test_convert_field_args_to_input_schema(): @@ -141,7 +161,7 @@ def test_convert_field_args_to_input_schema(): assert schema.properties["id"].type == "string" assert schema.properties["id"].description == "The entity ID" assert "limit" in schema.properties - assert schema.properties["limit"].type == "integer" + assert schema.properties["limit"].type == ["integer", "null"] assert schema.properties["limit"].default == 10 assert schema.required == ["id"] @@ -160,7 +180,9 @@ def test_convert_field_output_to_output_schema(): assert schema.type == "object" assert schema.properties is not None assert "id" in schema.properties + assert schema.properties["id"].type == "string" assert "name" in schema.properties + assert schema.properties["name"].type == ["string", "null"] def test_format_graphql_type_string(): @@ -204,8 +226,6 @@ def test_generate_default_selection_set_object_includes_scalars_and_nested(): def test_generate_default_selection_set_union(): """Unions generate inline fragments with __typename.""" - from graphql import GraphQLUnionType - Dog = GraphQLObjectType("Dog", {"name": GraphQLField(GraphQLString), "bark": GraphQLField(GraphQLString)}) Cat = GraphQLObjectType("Cat", {"name": GraphQLField(GraphQLString), "meow": GraphQLField(GraphQLString)}) Pet = GraphQLUnionType("Pet", types=[Dog, Cat]) @@ -217,7 +237,7 @@ def test_generate_default_selection_set_union(): def test_generate_default_selection_set_cycle_protection(): - """Circular references stop at max depth without crashing.""" + """Circular references stop at max depth without crashing and assert complete bounded string.""" User = GraphQLObjectType( "User", lambda: { @@ -226,8 +246,7 @@ def test_generate_default_selection_set_cycle_protection(): }, ) selection = generate_default_selection_set(User, max_depth=2) - assert "id" in selection - assert "bestFriend" in selection + assert selection == "{ id bestFriend { id } }" def test_generate_default_selection_set_skips_required_argument_fields(): @@ -248,3 +267,240 @@ def test_generate_default_selection_set_skips_required_argument_fields(): assert "name" in selection assert "parameterizedField" not in selection + +# ========================================================================= +# Regression tests for Review Findings (Cubic Issues 1-12) +# ========================================================================= + +def test_defaulted_non_null_arguments_and_input_fields_not_required(): + """Finding 10: Non-null arguments and input fields with default values must not be required.""" + field = GraphQLField( + GraphQLString, + args={ + "requiredArg": GraphQLArgument(GraphQLNonNull(GraphQLString)), + "defaultedArg": GraphQLArgument(GraphQLNonNull(GraphQLString), default_value="default_val"), + "optionalArg": GraphQLArgument(GraphQLInt), + }, + ) + schema = convert_field_args_to_input_schema(field) + assert schema.required == ["requiredArg"] + assert "defaultedArg" not in (schema.required or []) + assert schema.properties["defaultedArg"].type == "string" + assert schema.properties["defaultedArg"].default == "default_val" + + # Input object fields + inp = GraphQLInputObjectType( + "ConfigInput", + { + "mandatory": GraphQLInputField(GraphQLNonNull(GraphQLString)), + "withDefault": GraphQLInputField(GraphQLNonNull(GraphQLInt), default_value=5), + "optional": GraphQLInputField(GraphQLString), + }, + ) + inp_schema = convert_graphql_type_to_json_schema(inp) + assert inp_schema.required == ["mandatory"] + assert "withDefault" not in (inp_schema.required or []) + assert inp_schema.properties["withDefault"].default == 5 + + +def test_json_scalar_unconstrained_types(): + """Finding 8: JSON scalar supports unconstrained types, JSONObject supports object.""" + json_scalar = GraphQLScalarType("JSON") + schema_json = convert_graphql_type_to_json_schema(json_scalar) + assert schema_json.type == ["object", "array", "string", "number", "boolean", "null"] + + json_obj_scalar = GraphQLScalarType("JSONObject") + schema_obj = convert_graphql_type_to_json_schema(json_obj_scalar) + assert schema_obj.type == ["object", "null"] + + # Non-null JSON and JSONObject + nn_json = convert_graphql_type_to_json_schema(GraphQLNonNull(json_scalar)) + assert nn_json.type == ["object", "array", "string", "number", "boolean"] + + nn_obj = convert_graphql_type_to_json_schema(GraphQLNonNull(json_obj_scalar)) + assert nn_obj.type == "object" + + +def test_union_output_schema_one_of(): + """Finding 9: Union return schemas are represented using member-specific oneOf schemas.""" + Dog = GraphQLObjectType("Dog", {"name": GraphQLField(GraphQLString), "bark": GraphQLField(GraphQLString)}) + Cat = GraphQLObjectType("Cat", {"name": GraphQLField(GraphQLString), "meow": GraphQLField(GraphQLString)}) + Pet = GraphQLUnionType("Pet", types=[Dog, Cat]) + + # Nullable union + schema_nullable = convert_graphql_type_to_json_schema(Pet) + assert schema_nullable.oneOf is not None + assert len(schema_nullable.oneOf) == 3 # Dog, Cat, null + assert any(s.type == "null" for s in schema_nullable.oneOf) + + # Non-null union + schema_nn = convert_graphql_type_to_json_schema(GraphQLNonNull(Pet)) + assert schema_nn.oneOf is not None + assert len(schema_nn.oneOf) == 2 # Dog, Cat + assert not any(s.type == "null" for s in schema_nn.oneOf) + + +def test_nested_union_in_object_selection_set(): + """Finding 4: Support union-valued fields recursively inside object-returning fields.""" + Dog = GraphQLObjectType("Dog", {"name": GraphQLField(GraphQLString), "bark": GraphQLField(GraphQLString)}) + Cat = GraphQLObjectType("Cat", {"name": GraphQLField(GraphQLString), "meow": GraphQLField(GraphQLString)}) + Pet = GraphQLUnionType("Pet", types=[Dog, Cat]) + + SearchResult = GraphQLObjectType( + "SearchResult", + { + "totalCount": GraphQLField(GraphQLInt), + "pet": GraphQLField(Pet), + }, + ) + + selection = generate_default_selection_set(SearchResult, max_depth=3) + assert "totalCount" in selection + assert "pet { __typename ... on Dog { name bark } ... on Cat { name meow } }" in selection + + +def test_object_without_scalar_fields_fallback_typename(): + """Finding 3: Valid leaf or __typename fallback instead of selecting first field blindly.""" + Sub = GraphQLObjectType("Sub", {"id": GraphQLField(GraphQLString)}) + EmptyScalars = GraphQLObjectType( + "EmptyScalars", + { + "subWithReqArg": GraphQLField( + Sub, + args={"mandatory": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ), + }, + ) + selection = generate_default_selection_set(EmptyScalars, max_depth=2) + assert selection == "{ __typename }" + + +def test_depth_limit_without_identifier_fallback_typename(): + """Finding 12: Depth limit on object without id/code/name returns { __typename }.""" + Coordinate = GraphQLObjectType( + "Coordinate", + { + "lat": GraphQLField(GraphQLFloat), + "lng": GraphQLField(GraphQLFloat), + }, + ) + # At max_depth=0 (or recursing beyond depth limit without id/name/code) + selection = generate_default_selection_set(Coordinate, max_depth=0) + assert selection == "{ __typename }" + + +def test_selection_depth_validation_ge_1(): + """Finding 11: selection_depth accepts >= 1 and rejects 0 or negative values.""" + template_valid = GraphQLCallTemplate( + name="test", + url="https://example.com/graphql", + selection_depth=1, + ) + assert template_valid.selection_depth == 1 + + with pytest.raises(ValidationError): + GraphQLCallTemplate( + name="test", + url="https://example.com/graphql", + selection_depth=0, + ) + + with pytest.raises(ValidationError): + GraphQLCallTemplate( + name="test", + url="https://example.com/graphql", + selection_depth=-3, + ) + + +@pytest.mark.asyncio +async def test_operation_type_root_precedence_on_collision(): + """Finding 2: Resolve field using configured operation_type root first before falling back.""" + protocol = GraphQLCommunicationProtocol() + ItemType = GraphQLObjectType("Item", {"id": GraphQLField(GraphQLString)}) + schema = GraphQLSchema( + query=GraphQLObjectType("Query", {"item": GraphQLField(ItemType)}), + mutation=GraphQLObjectType( + "Mutation", + { + "item": GraphQLField(ItemType), + "createItem": GraphQLField(ItemType), + }, + ), + ) + + # 1. Operation type 'query' on colliding 'item' field -> generates query + template_q = GraphQLCallTemplate( + name="test_api", + url="https://example.com/graphql", + operation_type="query", + ) + mock_session_q = AsyncMock() + mock_session_q.client.schema = schema + mock_client_q = MagicMock() + mock_client_q.__aenter__.return_value = mock_session_q + + with patch("utcp_gql.gql_communication_protocol.GqlClient", return_value=mock_client_q), \ + patch("utcp_gql.gql_communication_protocol.gql_query") as mock_gql: + await protocol.call_tool(None, "item", {}, template_q) + executed_doc = mock_gql.call_args[0][0] + assert executed_doc.startswith("query") + + # 2. Operation type 'mutation' on colliding 'item' field -> generates mutation + template_m = GraphQLCallTemplate( + name="test_api", + url="https://example.com/graphql", + operation_type="mutation", + ) + mock_session_m = AsyncMock() + mock_session_m.client.schema = schema + mock_client_m = MagicMock() + mock_client_m.__aenter__.return_value = mock_session_m + + with patch("utcp_gql.gql_communication_protocol.GqlClient", return_value=mock_client_m), \ + patch("utcp_gql.gql_communication_protocol.gql_query") as mock_gql: + await protocol.call_tool(None, "item", {}, template_m) + executed_doc = mock_gql.call_args[0][0] + assert executed_doc.startswith("mutation") + + # 3. Operation type 'query', but field 'createItem' only exists on mutation -> falls back to mutation + mock_session_fb = AsyncMock() + mock_session_fb.client.schema = schema + mock_client_fb = MagicMock() + mock_client_fb.__aenter__.return_value = mock_session_fb + + with patch("utcp_gql.gql_communication_protocol.GqlClient", return_value=mock_client_fb), \ + patch("utcp_gql.gql_communication_protocol.gql_query") as mock_gql: + await protocol.call_tool(None, "createItem", {}, template_q) + executed_doc = mock_gql.call_args[0][0] + assert executed_doc.startswith("mutation") + + +@pytest.mark.asyncio +async def test_session_execute_no_duplicate_mutation_on_type_error(): + """Finding 1: Compatibility fallback must not catch TypeError from session.execute and retry.""" + protocol = GraphQLCommunicationProtocol() + template = GraphQLCallTemplate( + name="test_api", + url="https://example.com/graphql", + operation_type="mutation", + ) + ItemType = GraphQLObjectType("Item", {"id": GraphQLField(GraphQLString)}) + schema = GraphQLSchema( + mutation=GraphQLObjectType("Mutation", {"createItem": GraphQLField(ItemType)}) + ) + mock_session = AsyncMock() + mock_session.client.schema = schema + mock_session.execute.side_effect = TypeError("Post-request serialization error") + + mock_client = MagicMock() + mock_client.__aenter__.return_value = mock_session + + with patch("utcp_gql.gql_communication_protocol.GqlClient", return_value=mock_client): + with pytest.raises(TypeError, match="Post-request serialization error"): + await protocol.call_tool(None, "createItem", {}, template) + + # Must be executed exactly once, NEVER retried + assert mock_session.execute.call_count == 1 + + From e8d022f880c6acea5ffa73a634138f25cf38fd82 Mon Sep 17 00:00:00 2001 From: Sooriya04 Date: Sat, 31 Oct 2026 21:21:40 +0530 Subject: [PATCH 3/3] fix(gql): add required __typename const discriminator to union member schemas to prevent oneOf overlap --- .../gql/src/utcp_gql/schema_converter.py | 16 ++++++++++++---- .../gql/tests/test_schema_converter.py | 10 +++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/plugins/communication_protocols/gql/src/utcp_gql/schema_converter.py b/plugins/communication_protocols/gql/src/utcp_gql/schema_converter.py index b16fa7b..27eb728 100644 --- a/plugins/communication_protocols/gql/src/utcp_gql/schema_converter.py +++ b/plugins/communication_protocols/gql/src/utcp_gql/schema_converter.py @@ -193,16 +193,24 @@ def convert_graphql_type_to_json_schema( # Union types if is_union_type(gql_type): desc = description or getattr(gql_type, "description", None) - member_schemas = [ - convert_graphql_type_to_json_schema( + member_schemas = [] + for member in gql_type.types: + m_schema = convert_graphql_type_to_json_schema( member, visited=visited, depth=depth + 1, max_depth=max_depth, nullable=False, ) - for member in gql_type.types - ] + # Add required __typename const discriminator to make member schemas mutually exclusive + m_props = dict(m_schema.properties) if m_schema.properties else {} + m_props["__typename"] = JsonSchema(type="string", const=member.name) + m_req = list(m_schema.required) if m_schema.required else [] + if "__typename" not in m_req: + m_req.append("__typename") + member_schemas.append( + m_schema.model_copy(update={"properties": m_props, "required": m_req}) + ) if nullable: member_schemas.append(JsonSchema(type="null")) return JsonSchema( diff --git a/plugins/communication_protocols/gql/tests/test_schema_converter.py b/plugins/communication_protocols/gql/tests/test_schema_converter.py index bb8ff14..095dd52 100644 --- a/plugins/communication_protocols/gql/tests/test_schema_converter.py +++ b/plugins/communication_protocols/gql/tests/test_schema_converter.py @@ -322,7 +322,7 @@ def test_json_scalar_unconstrained_types(): def test_union_output_schema_one_of(): - """Finding 9: Union return schemas are represented using member-specific oneOf schemas.""" + """Finding 9: Union return schemas are represented using member-specific oneOf schemas with __typename const discriminator.""" Dog = GraphQLObjectType("Dog", {"name": GraphQLField(GraphQLString), "bark": GraphQLField(GraphQLString)}) Cat = GraphQLObjectType("Cat", {"name": GraphQLField(GraphQLString), "meow": GraphQLField(GraphQLString)}) Pet = GraphQLUnionType("Pet", types=[Dog, Cat]) @@ -333,6 +333,14 @@ def test_union_output_schema_one_of(): assert len(schema_nullable.oneOf) == 3 # Dog, Cat, null assert any(s.type == "null" for s in schema_nullable.oneOf) + # Verify __typename discriminator prevents overlap between Dog and Cat + dog_schema = next(s for s in schema_nullable.oneOf if s.properties and "__typename" in s.properties and s.properties["__typename"].const == "Dog") + cat_schema = next(s for s in schema_nullable.oneOf if s.properties and "__typename" in s.properties and s.properties["__typename"].const == "Cat") + assert "__typename" in dog_schema.required + assert "__typename" in cat_schema.required + assert dog_schema.properties["__typename"].const == "Dog" + assert cat_schema.properties["__typename"].const == "Cat" + # Non-null union schema_nn = convert_graphql_type_to_json_schema(GraphQLNonNull(Pet)) assert schema_nn.oneOf is not None