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..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 @@ -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,15 @@ 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, + ge=1, + description="Maximum depth of subfields to include when auto-generating selection sets." + ) @field_serializer("auth") def serialize_auth(self, auth: Optional[Auth]): @@ -90,4 +103,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..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 @@ -16,6 +17,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 +158,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 +248,95 @@ 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: + 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: 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) + request_obj = None + try: + from gql import GraphQLRequest + + 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 async def call_tool_streaming( @@ -269,4 +351,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..27eb728 --- /dev/null +++ b/plugins/communication_protocols/gql/src/utcp_gql/schema_converter.py @@ -0,0 +1,362 @@ +"""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, Union +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", "array", "string", "number", "boolean", "null"]}, + "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 _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, + default_value: Optional[JsonType] = None, + 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 and nullability preservation. + """ + if visited is None: + visited = set() + + # 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, + description=description, + default_value=default_value, + visited=visited, + depth=depth, + max_depth=max_depth, + nullable=False, + ) + + # 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=_apply_nullability("array", nullable), + 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) + base_type = mapping.get("type", "string") + return JsonSchema( + type=_apply_nullability(base_type, nullable), + 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=_apply_nullability("string", nullable), + 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=_apply_nullability("object", nullable), 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) and (input_field.default_value is None or input_field.default_value is Undefined): + 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=_apply_nullability("object", nullable), + 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=_apply_nullability("object", nullable), 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=_apply_nullability("object", nullable), + 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) + 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, + ) + # 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( + description=desc, + default=default_value, + oneOf=member_schemas, # type: ignore[call-arg] + ) + + 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 without defaults 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) and (arg.default_value is None or arg.default_value is Undefined): + 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, or __typename + for id_field in ("id", "code", "name"): + if id_field in actual_type.fields: + return f"{{ {id_field} }}" + return "{ __typename }" + + 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) or is_union_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: + selections.append("__typename") + + return f"{{ {' '.join(selections)} }}" + + 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..095dd52 --- /dev/null +++ b/plugins/communication_protocols/gql/tests/test_schema_converter.py @@ -0,0 +1,514 @@ +"""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. +""" + +from unittest.mock import AsyncMock, MagicMock, patch +import pytest +from pydantic import ValidationError +from graphql import ( + GraphQLArgument, + GraphQLEnumType, + GraphQLEnumValue, + GraphQLField, + GraphQLFloat, + GraphQLID, + GraphQLInputField, + GraphQLInputObjectType, + GraphQLInt, + GraphQLList, + 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, + 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 with nullability.""" + str_schema = convert_graphql_type_to_json_schema(GraphQLString, description="A 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", "null"] + assert int_schema.default == 42 + + float_schema = convert_graphql_type_to_json_schema(GraphQLFloat) + assert float_schema.type == ["number", "null"] + + id_schema = convert_graphql_type_to_json_schema(GraphQLID) + 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", "null"] + assert dt_schema.format == "date-time" + + +def test_convert_non_null_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, preserving nullability.""" + list_of_ints = GraphQLList(GraphQLInt) + schema = convert_graphql_type_to_json_schema(list_of_ints) + assert schema.type == ["array", "null"] + assert schema.items is not None + 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 and nullability.""" + 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", "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.""" + 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", "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", "null"] + assert schema.properties["age"].default == 18 + assert "tags" in schema.properties + 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 and cut off properties.""" + 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", "null"] + assert schema.properties is not None + assert "name" in schema.properties + assert "parent" in schema.properties + 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(): + """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", "null"] + 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 schema.properties["id"].type == "string" + assert "name" in schema.properties + assert schema.properties["name"].type == ["string", "null"] + + +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.""" + 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 and assert complete bounded string.""" + User = GraphQLObjectType( + "User", + lambda: { + "id": GraphQLField(GraphQLNonNull(GraphQLID)), + "bestFriend": GraphQLField(User), + }, + ) + selection = generate_default_selection_set(User, max_depth=2) + assert selection == "{ id bestFriend { id } }" + + +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 + + +# ========================================================================= +# 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 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]) + + # 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) + + # 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 + 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 + +