Repository: mirumee/ariadne

Issue:
Bind Python Types to GraphQL Input Types [How To]
Perhaps it's just unclear in the docs, but I don't see any way to automatically convert from the input dictionaries given by Ariadne, to my resolvers, into custom Python types (i.e. dataclasses)

Is this supported? I found the docs on bindables, but from just that I can't tell if that's what I want. 

Example

```python
@dataclass
class Person:
    id: int
    name: str
    phone_number: str


@mutation.field('updatePerson')
def update_person(_, info, person: Person):
    # Currently what comes in is a dict. 
    # Is there a way to automatically map this to Person?
    do_update(
        person.id, 
        name=person.name, 
        phone=person.phone_number
    )
```

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c62e076..75796d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 ## Next (UNRELEASED)
 
+- Added `InputType` for setting Python representations of GraphQL Input types
 - Added support for passing `Enum` types directly to `make_executable_schema`
 - Added `convert_names_case` option to `make_federated_schema`.
 
diff --git a/ariadne/__init__.py b/ariadne/__init__.py
index eb1eb3b..fe0c343 100644
--- a/ariadne/__init__.py
+++ b/ariadne/__init__.py
@@ -13,6 +13,7 @@ from .format_error import (
     get_formatted_error_traceback,
 )
 from .graphql import graphql, graphql_sync, subscribe
+from .inputs import InputType
 from .interfaces import InterfaceType, type_implements_interface
 from .load_schema import load_schema_from_path
 from .objects import MutationType, ObjectType, QueryType
@@ -43,6 +44,7 @@ __all__ = [
     "ExtensionManager",
     "ExtensionSync",
     "FallbackResolversSetter",
+    "InputType",
     "InterfaceType",
     "MutationType",
     "ObjectType",
diff --git a/ariadne/executable_schema.py b/ariadne/executable_schema.py
index 03968fe..0d99184 100644
--- a/ariadne/executable_schema.py
+++ b/ariadne/executable_schema.py
@@ -18,7 +18,9 @@ from .schema_visitor import SchemaDirectiveVisitor
 from .types import SchemaBindable
 
 SchemaBindables = Union[
-    SchemaBindable, Type[Enum], List[Union[SchemaBindable, Type[Enum]]]
+    SchemaBindable,
+    Type[Enum],
+    List[Union[SchemaBindable, Type[Enum]]],
 ]
 
 
@@ -365,7 +367,9 @@ def join_type_defs(type_defs: List[str]) -> str:
     return "\n\n".join(t.strip() for t in type_defs)
 
 
-def normalize_bindables(*bindables: SchemaBindables) -> List[SchemaBindable]:
+def normalize_bindables(
+    *bindables: SchemaBindables,
+) -> List[SchemaBindable]:
     normal_bindables: List[SchemaBindable] = []
     for bindable in flatten_bindables(*bindables):
         if isinstance(bindable, SchemaBindable):
diff --git a/ariadne/inputs.py b/ariadne/inputs.py
new file mode 100644
index 0000000..53d3dd1
--- /dev/null
+++ b/ariadne/inputs.py
@@ -0,0 +1,204 @@
+from typing import Dict, Optional, cast
+
+from graphql import GraphQLInputObjectType, GraphQLSchema
+from graphql.type.definition import GraphQLInputFieldOutType, GraphQLNamedType
+
+from .types import SchemaBindable
+
+
+class InputType(SchemaBindable):
+    """Bindable populating input types in a GraphQL schema with Python logic.
+
+    # Example input value represented as dataclass
+
+    Following code creates a GraphQL schema with object type named `Query`
+    with single field which has an argument of an input type. It then uses
+    the `InputType` to set `ExampleInput` dataclass as Python representation
+    of this GraphQL type:
+
+    ```python
+    from dataclasses import dataclass
+
+    from ariadne import InputType, QueryType, make_executable_schema
+
+    @dataclass
+    class ExampleInput:
+        id: str
+        message: str
+
+    query_type = QueryType()
+
+    @query_type.field("repr")
+    def resolve_repr(*_, input: ExampleInput):
+        return repr(input)
+
+    schema = make_executable_schema(
+        \"\"\"
+        type Query {
+            repr(input: ExampleInput): String!
+        }
+
+        input ExampleInput {
+            id: ID!
+            message: String!
+        }
+        \"\"\",
+        query_type,
+        # Lambda is used because out type (second argument of InputType)
+        # is called with single dict and dataclass requires each value as
+        # separate argument.
+        InputType("ExampleInput", lambda data: ExampleInput(**data)),
+    )
+    ```
+
+    # Example input with its fields mapped to custom dict keys
+
+    Following code creates a GraphQL schema with object type named `Query`
+    with single field which has an argument of an input type. It then uses
+    the `InputType` to set custom "out names" values, mapping GraphQL
+    `shortMessage` to `message` key in Python dict:
+
+    ```python
+    from ariadne import InputType, QueryType, make_executable_schema
+
+    query_type = QueryType()
+
+    @query_type.field("repr")
+    def resolve_repr(*_, input: dict):
+        # Dict will have `id` and `message` keys
+        input_id = input["id"]
+        input_message = input["message"]
+        return f"id: {input_id}, message: {input_message}"
+
+    schema = make_executable_schema(
+        \"\"\"
+        type Query {
+            repr(input: ExampleInput): String!
+        }
+
+        input ExampleInput {
+            id: ID!
+            shortMessage: String!
+        }
+        \"\"\",
+        query_type,
+        InputType("ExampleInput", out_names={"shortMessage": "message"}),
+    )
+    ```
+
+    # Example input value as dataclass with custom named fields
+
+    Following code creates a GraphQL schema with object type named `Query`
+    with single field which has an argument of an input type. It then uses
+    the `InputType` to set `ExampleInput` dataclass as Python representation
+    of this GraphQL type, and maps `shortMessage` input field to it's
+    `message` attribute:
+
+    ```python
+    from dataclasses import dataclass
+
+    from ariadne import InputType, QueryType, make_executable_schema
+
+    @dataclass
+    class ExampleInput:
+        id: str
+        message: str
+
+    query_type = QueryType()
+
+    @query_type.field("repr")
+    def resolve_repr(*_, input: ExampleInput):
+        return repr(input)
+
+    schema = make_executable_schema(
+        \"\"\"
+        type Query {
+            repr(input: ExampleInput): String!
+        }
+
+        input ExampleInput {
+            id: ID!
+            shortMessage: String!
+        }
+        \"\"\",
+        query_type,
+        InputType(
+            "ExampleInput",
+            lambda data: ExampleInput(**data),
+            {"shortMessage": "message"},
+        ),
+    )
+    ```
+    """
+
+    _out_type: Optional[GraphQLInputFieldOutType]
+    _out_names: Optional[Dict[str, str]]
+
+    def __init__(
+        self,
+        name: str,
+        out_type: Optional[GraphQLInputFieldOutType] = None,
+        out_names: Optional[Dict[str, str]] = None,
+    ) -> None:
+        """Initializes the `InputType` with a `name` and optionally out type
+        and out names.
+
+        # Required arguments
+
+        `name`: a `str` with the name of GraphQL object type in GraphQL schema to
+        bind to.
+
+        # Optional arguments
+
+        `out_type`: a `GraphQLInputFieldOutType`, Python callable accepting single
+        argument, a dict with data from GraphQL query, required to return
+        a Python representation of input type.
+
+        `out_names`: a `Dict[str, str]` with mappings from GraphQL field names
+        to dict keys in a Python dictionary used to contain a data passed as
+        input.
+        """
+        self.name = name
+        self._out_type = out_type
+        self._out_names = out_names
+
+    def bind_to_schema(self, schema: GraphQLSchema) -> None:
+        """Binds this `InputType` instance to the instance of GraphQL schema.
+
+        if it has an out type function, it assigns it to GraphQL type's
+        `out_type` attribute. If type already has other function set on
+        it's `out_type` attribute, this type is replaced with new one.
+
+        If it has any out names set, it assigns those to GraphQL type's
+        fields `out_name` attributes. If field already has other out name set on
+        its `out_name` attribute, this name is replaced with the new one.
+        """
+        graphql_type = schema.type_map.get(self.name)
+        self.validate_graphql_type(graphql_type)
+        graphql_type = cast(GraphQLInputObjectType, graphql_type)
+
+        if self._out_type:
+            graphql_type.out_type = self._out_type  # type: ignore
+
+        if self._out_names:
+            for graphql_name, python_name in self._out_names.items():
+                if graphql_name not in graphql_type.fields:
+                    raise ValueError(
+                        f"Field {graphql_name} is not defined on type {self.name}"
+                    )
+                graphql_type.fields[graphql_name].out_name = python_name
+
+    def validate_graphql_type(self, graphql_type: Optional[GraphQLNamedType]) -> None:
+        """Validates that schema's GraphQL type associated with this `InputType`
+        is an `input`."""
+        if not graphql_type:
+            raise ValueError("Type %s is not defined in the schema" % self.name)
+        if not isinstance(graphql_type, GraphQLInputObjectType):
+            raise ValueError(
+                "%s is defined in the schema, but it is instance of %s (expected %s)"
+                % (
+                    self.name,
+                    type(graphql_type).__name__,
+                    GraphQLInputObjectType.__name__,
+                )
+            )


Test patch:
diff --git a/tests/test_inputs.py b/tests/test_inputs.py
new file mode 100644
index 0000000..3bccb13
--- /dev/null
+++ b/tests/test_inputs.py
@@ -0,0 +1,167 @@
+from dataclasses import dataclass
+
+import pytest
+from graphql import graphql_sync
+
+from ariadne import InputType, make_executable_schema
+
+
+@pytest.fixture
+def schema():
+    return make_executable_schema(
+        """
+            type Query {
+                repr(input: ExampleInput!): Boolean!
+            }
+
+            input ExampleInput {
+                id: ID
+                message: String
+                yearOfBirth: Int
+            }
+        """
+    )
+
+
+def set_repr_resolver(schema, repr_resolver):
+    # pylint: disable=redefined-builtin
+    def resolve_repr(*_, input):
+        repr_resolver(input)
+        return True
+
+    schema.type_map["Query"].fields["repr"].resolve = resolve_repr
+
+
+TEST_QUERY = """
+query InputTest($input: ExampleInput!) {
+    repr(input: $input)
+}
+"""
+
+
+def test_attempt_bind_input_type_to_undefined_type_raises_error(schema):
+    input_type = InputType("Test")
+    with pytest.raises(ValueError):
+        input_type.bind_to_schema(schema)
+
+
+def test_attempt_bind_input_type_to_invalid_type_raises_error(schema):
+    input_type = InputType("Query")
+    with pytest.raises(ValueError):
+        input_type.bind_to_schema(schema)
+
+
+def test_attempt_bind_input_type_out_name_to_undefined_field_raises_error(schema):
+    input_type = InputType("Query", out_names={"undefined": "Ok"})
+    with pytest.raises(ValueError):
+        input_type.bind_to_schema(schema)
+
+
+def test_bind_input_type_out_type_sets_custom_python_type_for_input(schema):
+    # pylint: disable=redefined-builtin
+
+    @dataclass
+    class InputDataclass:
+        id: str
+        message: str
+        yearOfBirth: int
+
+    input_type = InputType(
+        "ExampleInput",
+        out_type=lambda data: InputDataclass(**data),
+    )
+    input_type.bind_to_schema(schema)
+
+    def assert_input_type(input):
+        assert isinstance(input, InputDataclass)
+        assert input.id == "123"
+        assert input.message == "Lorem ipsum"
+        assert input.yearOfBirth == 2022
+
+    set_repr_resolver(schema, assert_input_type)
+
+    result = graphql_sync(
+        schema,
+        TEST_QUERY,
+        variable_values={
+            "input": {
+                "id": "123",
+                "message": "Lorem ipsum",
+                "yearOfBirth": 2022,
+            }
+        },
+    )
+
+    assert not result.errors
+
+
+def test_bind_input_type_out_names_sets_custom_python_dict_keys_for_input(schema):
+    # pylint: disable=redefined-builtin
+
+    input_type = InputType(
+        "ExampleInput",
+        out_names={"yearOfBirth": "year_of_birth"},
+    )
+    input_type.bind_to_schema(schema)
+
+    def assert_input_type(input):
+        assert input == {
+            "id": "123",
+            "message": "Lorem ipsum",
+            "year_of_birth": 2022,
+        }
+
+    set_repr_resolver(schema, assert_input_type)
+
+    result = graphql_sync(
+        schema,
+        TEST_QUERY,
+        variable_values={
+            "input": {
+                "id": "123",
+                "message": "Lorem ipsum",
+                "yearOfBirth": 2022,
+            }
+        },
+    )
+
+    assert not result.errors
+
+
+def test_bind_input_type_out_type_and_names_sets_custom_python_type_for_input(schema):
+    # pylint: disable=redefined-builtin
+
+    @dataclass
+    class InputDataclass:
+        id: str
+        message: str
+        year_of_birth: int
+
+    input_type = InputType(
+        "ExampleInput",
+        out_type=lambda data: InputDataclass(**data),
+        out_names={"yearOfBirth": "year_of_birth"},
+    )
+    input_type.bind_to_schema(schema)
+
+    def assert_input_type(input):
+        assert isinstance(input, InputDataclass)
+        assert input.id == "123"
+        assert input.message == "Lorem ipsum"
+        assert input.year_of_birth == 2022
+
+    set_repr_resolver(schema, assert_input_type)
+
+    result = graphql_sync(
+        schema,
+        TEST_QUERY,
+        variable_values={
+            "input": {
+                "id": "123",
+                "message": "Lorem ipsum",
+                "yearOfBirth": 2022,
+            }
+        },
+    )
+
+    assert not result.errors
diff --git a/tests_mypy/inputs.py b/tests_mypy/inputs.py
new file mode 100644
index 0000000..686c738
--- /dev/null
+++ b/tests_mypy/inputs.py
@@ -0,0 +1,14 @@
+from dataclasses import dataclass
+
+from ariadne import InputType
+
+
+@dataclass
+class SomeInput:
+    id: str
+    message: str
+
+
+dataclass_input = InputType("Name", lambda data: SomeInput(**data))
+
+dict_input = InputType("Name", out_names={"fieldName": "field_name"})