diff --git a/gql/dsl.py b/gql/dsl.py index f3bd1fe2..1646d402 100644 --- a/gql/dsl.py +++ b/gql/dsl.py @@ -1,15 +1,22 @@ import logging +import re from abc import ABC, abstractmethod +from math import isfinite from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union, cast from graphql import ( ArgumentNode, + BooleanValueNode, DocumentNode, + EnumValueNode, FieldNode, + FloatValueNode, FragmentDefinitionNode, FragmentSpreadNode, GraphQLArgument, + GraphQLError, GraphQLField, + GraphQLID, GraphQLInputObjectType, GraphQLInputType, GraphQLInterfaceType, @@ -20,6 +27,7 @@ GraphQLSchema, GraphQLWrappingType, InlineFragmentNode, + IntValueNode, ListTypeNode, ListValueNode, NamedTypeNode, @@ -31,25 +39,76 @@ OperationDefinitionNode, OperationType, SelectionSetNode, + StringValueNode, TypeNode, Undefined, ValueNode, VariableDefinitionNode, VariableNode, assert_named_type, + is_enum_type, is_input_object_type, + is_leaf_type, is_list_type, is_non_null_type, is_wrapping_type, print_ast, ) -from graphql.pyutils import FrozenList -from graphql.utilities import ast_from_value as default_ast_from_value +from graphql.pyutils import FrozenList, inspect from .utils import to_camel_case log = logging.getLogger(__name__) +_re_integer_string = re.compile("^-?(?:0|[1-9][0-9]*)$") + + +def ast_from_serialized_value_untyped(serialized: Any) -> Optional[ValueNode]: + """Given a serialized value, try our best to produce an AST. + + Anything ressembling an array (instance of Mapping) will be converted + to an ObjectFieldNode. + + Anything ressembling a list (instance of Iterable - except str) + will be converted to a ListNode. + + In some cases, a custom scalar can be serialized differently in the query + than in the variables. In that case, this function will not work.""" + + if serialized is None or serialized is Undefined: + return NullValueNode() + + if isinstance(serialized, Mapping): + field_items = ( + (key, ast_from_serialized_value_untyped(value)) + for key, value in serialized.items() + ) + field_nodes = ( + ObjectFieldNode(name=NameNode(value=field_name), value=field_value) + for field_name, field_value in field_items + if field_value + ) + return ObjectValueNode(fields=FrozenList(field_nodes)) + + if isinstance(serialized, Iterable) and not isinstance(serialized, str): + maybe_nodes = (ast_from_serialized_value_untyped(item) for item in serialized) + nodes = filter(None, maybe_nodes) + return ListValueNode(values=FrozenList(nodes)) + + if isinstance(serialized, bool): + return BooleanValueNode(value=serialized) + + if isinstance(serialized, int): + return IntValueNode(value=f"{serialized:d}") + + if isinstance(serialized, float) and isfinite(serialized): + return FloatValueNode(value=f"{serialized:g}") + + if isinstance(serialized, str): + return StringValueNode(value=serialized) + + raise TypeError(f"Cannot convert value to AST: {inspect(serialized)}.") + def ast_from_value(value: Any, type_: GraphQLInputType) -> Optional[ValueNode]: """ @@ -60,15 +119,21 @@ def ast_from_value(value: Any, type_: GraphQLInputType) -> Optional[ValueNode]: VariableNode when value is a DSLVariable Produce a GraphQL Value AST given a Python object. + + Raises a GraphQLError instead of returning None if we receive an Undefined + of if we receive a Null value for a Non-Null type. """ if isinstance(value, DSLVariable): return value.set_type(type_).ast_variable if is_non_null_type(type_): type_ = cast(GraphQLNonNull, type_) - ast_value = ast_from_value(value, type_.of_type) + inner_type = type_.of_type + ast_value = ast_from_value(value, inner_type) if isinstance(ast_value, NullValueNode): - return None + raise GraphQLError( + "Received Null value for a Non-Null type " f"{inspect(inner_type)}." + ) return ast_value # only explicit None, not Undefined or NaN @@ -77,7 +142,7 @@ def ast_from_value(value: Any, type_: GraphQLInputType) -> Optional[ValueNode]: # undefined if value is Undefined: - return None + raise GraphQLError(f"Received Undefined value for type {inspect(type_)}.") # Convert Python list to GraphQL list. If the GraphQLType is a list, but the value # is not a list, convert the value using the list's item type. @@ -108,7 +173,32 @@ def ast_from_value(value: Any, type_: GraphQLInputType) -> Optional[ValueNode]: ) return ObjectValueNode(fields=FrozenList(field_nodes)) - return default_ast_from_value(value, type_) + if is_leaf_type(type_): + # Since value is an internally represented value, it must be serialized to an + # externally represented value before converting into an AST. + serialized = type_.serialize(value) # type: ignore + + # if the serialized value is a string, then we should use the + # type to determine if it is an enum, an ID or a normal string + if isinstance(serialized, str): + # Enum types use Enum literals. + if is_enum_type(type_): + return EnumValueNode(value=serialized) + + # ID types can use Int literals. + if type_ is GraphQLID and _re_integer_string.match(serialized): + return IntValueNode(value=serialized) + + return StringValueNode(value=serialized) + + # Some custom scalars will serialize to dicts or lists + # Providing here a default conversion to AST using our best judgment + # until graphql-js issue #1817 is solved + # https://github.com/graphql/graphql-js/issues/1817 + return ast_from_serialized_value_untyped(serialized) + + # Not reachable. All possible input types have been considered. + raise TypeError(f"Unexpected input type: {inspect(type_)}.") def dsl_gql( diff --git a/tests/custom_scalars/test_custom_scalar_json.py b/tests/custom_scalars/test_custom_scalar_json.py new file mode 100644 index 00000000..80f99850 --- /dev/null +++ b/tests/custom_scalars/test_custom_scalar_json.py @@ -0,0 +1,241 @@ +from typing import Any, Dict, Optional + +import pytest +from graphql import ( + GraphQLArgument, + GraphQLError, + GraphQLField, + GraphQLFloat, + GraphQLInt, + GraphQLNonNull, + GraphQLObjectType, + GraphQLScalarType, + GraphQLSchema, +) +from graphql.language import ValueNode +from graphql.utilities import value_from_ast_untyped + +from gql import Client, gql +from gql.dsl import DSLSchema + +# Marking all tests in this file with the aiohttp marker +pytestmark = pytest.mark.aiohttp + + +def serialize_json(value: Any) -> Dict[str, Any]: + return value + + +def parse_json_value(value: Any) -> Any: + return value + + +def parse_json_literal( + value_node: ValueNode, variables: Optional[Dict[str, Any]] = None +) -> Any: + return value_from_ast_untyped(value_node, variables) + + +JsonScalar = GraphQLScalarType( + name="JSON", + serialize=serialize_json, + parse_value=parse_json_value, + parse_literal=parse_json_literal, +) + +root_value = { + "players": [ + { + "name": "John", + "level": 3, + "is_connected": True, + "score": 123.45, + "friends": ["Alex", "Alicia"], + }, + { + "name": "Alex", + "level": 4, + "is_connected": False, + "score": 1337.69, + "friends": None, + }, + ] +} + + +def resolve_players(root, _info): + return root["players"] + + +queryType = GraphQLObjectType( + name="Query", fields={"players": GraphQLField(JsonScalar, resolve=resolve_players)}, +) + + +def resolve_add_player(root, _info, player): + print(f"player = {player!r}") + root["players"].append(player) + return {"players": root["players"]} + + +mutationType = GraphQLObjectType( + name="Mutation", + fields={ + "addPlayer": GraphQLField( + JsonScalar, + args={"player": GraphQLArgument(GraphQLNonNull(JsonScalar))}, + resolve=resolve_add_player, + ) + }, +) + +schema = GraphQLSchema(query=queryType, mutation=mutationType) + + +def test_json_value_output(): + + client = Client(schema=schema) + + query = gql("query {players}") + + result = client.execute(query, root_value=root_value) + + print(result) + + assert result["players"] == serialize_json(root_value["players"]) + + +def test_json_value_input_in_ast(): + + client = Client(schema=schema) + + query = gql( + """ + mutation adding_player { + addPlayer(player: { + name: "Tom", + level: 1, + is_connected: True, + score: 0, + friends: [ + "John" + ] + }) +}""" + ) + + result = client.execute(query, root_value=root_value) + + print(result) + + players = result["addPlayer"]["players"] + + assert players == serialize_json(root_value["players"]) + assert players[-1]["name"] == "Tom" + + +def test_json_value_input_in_ast_with_variables(): + + print(f"{schema.type_map!r}") + client = Client(schema=schema) + + # Note: we need to manually add the built-in types which + # are not present in the schema + schema.type_map["Int"] = GraphQLInt + schema.type_map["Float"] = GraphQLFloat + + query = gql( + """ + mutation adding_player( + $name: String!, + $level: Int!, + $is_connected: Boolean, + $score: Float!, + $friends: [String!]!) { + + addPlayer(player: { + name: $name, + level: $level, + is_connected: $is_connected, + score: $score, + friends: $friends, + }) +}""" + ) + + variable_values = { + "name": "Barbara", + "level": 1, + "is_connected": False, + "score": 69, + "friends": ["Alex", "John"], + } + + result = client.execute( + query, variable_values=variable_values, root_value=root_value + ) + + print(result) + + players = result["addPlayer"]["players"] + + assert players == serialize_json(root_value["players"]) + assert players[-1]["name"] == "Barbara" + + +def test_json_value_input_in_dsl_argument(): + + ds = DSLSchema(schema) + + new_player = { + "name": "Tim", + "level": 0, + "is_connected": False, + "score": 5, + "friends": ["Lea"], + } + + query = ds.Mutation.addPlayer(player=new_player) + + print(str(query)) + + assert ( + str(query) + == """addPlayer( + player: {name: "Tim", level: 0, is_connected: false, score: 5, friends: ["Lea"]} +)""" + ) + + +def test_none_json_value_input_in_dsl_argument(): + + ds = DSLSchema(schema) + + with pytest.raises(GraphQLError) as exc_info: + ds.Mutation.addPlayer(player=None) + + assert "Received Null value for a Non-Null type JSON." in str(exc_info.value) + + +def test_json_value_input_with_none_list_in_dsl_argument(): + + ds = DSLSchema(schema) + + new_player = { + "name": "Bob", + "level": 9001, + "is_connected": True, + "score": 666.66, + "friends": None, + } + + query = ds.Mutation.addPlayer(player=new_player) + + print(str(query)) + + assert ( + str(query) + == """addPlayer( + player: {name: "Bob", level: 9001, is_connected: true, score: 666.66, friends: null} +)""" + ) diff --git a/tests/starwars/test_dsl.py b/tests/starwars/test_dsl.py index 93de6c03..d18bb37d 100644 --- a/tests/starwars/test_dsl.py +++ b/tests/starwars/test_dsl.py @@ -1,5 +1,7 @@ import pytest from graphql import ( + GraphQLError, + GraphQLID, GraphQLInt, GraphQLList, GraphQLNonNull, @@ -23,6 +25,7 @@ DSLSubscription, DSLVariable, DSLVariableDefinitions, + ast_from_serialized_value_untyped, ast_from_value, dsl_gql, ) @@ -54,12 +57,38 @@ def test_ast_from_value_with_none(): def test_ast_from_value_with_undefined(): - assert ast_from_value(Undefined, GraphQLInt) is None + with pytest.raises(GraphQLError) as exc_info: + ast_from_value(Undefined, GraphQLInt) + + assert "Received Undefined value for type Int." in str(exc_info.value) + + +def test_ast_from_value_with_graphqlid(): + + assert ast_from_value("12345", GraphQLID) == IntValueNode(value="12345") + + +def test_ast_from_value_with_invalid_type(): + with pytest.raises(TypeError) as exc_info: + ast_from_value(4, None) + + assert "Unexpected input type: None." in str(exc_info.value) def test_ast_from_value_with_non_null_type_and_none(): typ = GraphQLNonNull(GraphQLInt) - assert ast_from_value(None, typ) is None + + with pytest.raises(GraphQLError) as exc_info: + ast_from_value(None, typ) + + assert "Received Null value for a Non-Null type Int." in str(exc_info.value) + + +def test_ast_from_serialized_value_untyped_typeerror(): + with pytest.raises(TypeError) as exc_info: + ast_from_serialized_value_untyped(GraphQLInt) + + assert "Cannot convert value to AST: Int." in str(exc_info.value) def test_variable_to_ast_type_passing_wrapping_type():