Repository: strawberry-graphql/strawberry

Issue:
resolve_async missing from NodeExtension
## Describe the Bug

One new bug from `graphql/type/definition.py", line 811` as `TypeError: Query fields cannot be resolved. Cannot add sync-only extension(s) NodeExtension to the async resolver of Field hardware_details. Please add a resolve_async method to the extension(s).`

As we can see at https://github.com/strawberry-graphql/strawberry/blob/main/strawberry/relay/fields.py#L59 there is no reference to `resolve_async` so we must assume this would never work with the underlying `gql` library.

The potential quick fix is as follows:
```python
class NodeExtension(FieldExtension):
    def apply(self, field: StrawberryField) -> None:
        assert field.base_resolver is None

        if isinstance(field.type, StrawberryList):
            resolver = self.get_node_list_resolver(field)
        else:
            resolver = self.get_node_resolver(field)

        field.base_resolver = StrawberryResolver(resolver, type_override=field.type)

    async def resolve_async(
        self, next_: AsyncExtensionResolver, source: Any, info: Info, **kwargs: Any
    ) -> Coroutine[Any, Any, Any]:
        return await next_(source, info, **kwargs)
```

having a query of the following that inherits from `relay.Node`

```python
@gql.django.type(
    hardware.models.Hardware,
    filters=HardwareFilter,
    order=HardwareOrder,
    pagination=True,
)
class Hardware(gql.relay.Node):
    ...
 
@strawberry.type
class Query:
    hardware_details: Hardware = gql.django.node(permission_classes=[IsAuthenticated])
```

Full stack trace:

```python
Traceback (most recent call last):
  File "/Users/dylan/spice/backend/manage.py", line 22, in <module>
    main()
  File "/Users/dylan/spice/backend/manage.py", line 18, in main
    execute_from_command_line(sys.argv)
  File "/Users/dylan/spice/backend/.venv/lib/python3.11/site-packages/django/core/management/__init__.py", line 442, in execute_from_command_line
    utility.execute()
  File "/Users/dylan/spice/backend/.venv/lib/python3.11/site-packages/django/core/management/__init__.py", line 436, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dylan/spice/backend/.venv/lib/python3.11/site-packages/django/core/management/__init__.py", line 275, in fetch_command
    klass = load_command_class(app_name, subcommand)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dylan/spice/backend/.venv/lib/python3.11/site-packages/django/core/management/__init__.py", line 48, in load_command_class
    module = import_module("%s.management.commands.%s" % (app_name, name))
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dylan/.pyenv/versions/3.11.3/lib/python3.11/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen importlib._bootstrap>", line 1206, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1178, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1149, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 690, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 940, in exec_module
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "/Users/dylan/spice/backend/graphql_api/management/commands/generate_schema.py", line 4, in <module>
    from graphql_api.schema import schema
  File "/Users/dylan/spice/backend/graphql_api/schema.py", line 33, in <module>
    schema = Schema(query=Query, mutation=Mutation)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dylan/spice/backend/.venv/lib/python3.11/site-packages/strawberry/schema/schema.py", line 140, in __init__
    self._schema = GraphQLSchema(
                   ^^^^^^^^^^^^^^
  File "/Users/dylan/spice/backend/.venv/lib/python3.11/site-packages/graphql/type/schema.py", line 224, in __init__
    collect_referenced_types(query)
  File "/Users/dylan/spice/backend/.venv/lib/python3.11/site-packages/graphql/type/schema.py", line 432, in collect_referenced_types
    for field in named_type.fields.values():
                 ^^^^^^^^^^^^^^^^^
  File "/Users/dylan/.pyenv/versions/3.11.3/lib/python3.11/functools.py", line 1001, in __get__
    val = self.func(instance)
          ^^^^^^^^^^^^^^^^^^^
  File "/Users/dylan/spice/backend/.venv/lib/python3.11/site-packages/graphql/type/definition.py", line 811, in fields
    raise cls(f"{self.name} fields cannot be resolved. {error}") from error
TypeError: Query fields cannot be resolved. Cannot add sync-only extension(s) NodeExtension to the async resolver of Field hardware_details. Please add a resolve_async method to the extension(s).
```


## System Information

 - Operating system: macOS Ventura 13.4 (22F66)
 - Strawberry version:
```bash
graphql-core               3.2.3
strawberry-django-plus     3.0.1
strawberry-graphql         0.186.1
strawberry-graphql-django  0.9.4
```



Discussion:
I determined this was actually due to the permission being added.

```python
# produces error
hardware_details: Hardware = relay.node(permission_classes=[IsAuthenticated])
# works
hardware_details: Hardware = relay.node()
```

a simple async permission is what causes this path to be exposed.

```
import typing

from strawberry.permission import BasePermission
from strawberry.types import Info


class IsAuthenticated(BasePermission):
    message = "User is not authenticated"

    async def has_permission(self, source: typing.Any, info: Info, **kwargs) -> bool:
        user = await info.context.request.auser()
        return user.is_authenticated

```

Hey @djstein ,

Hrm, interesting! I think the new [Permissions using Field Extensions](https://github.com/strawberry-graphql/strawberry/pull/2570) will help with this issue. I'll also migrate strawberry-django-plus' permissions code to use it as well.

Either way the extension is indeed missing a `resolve_async` method, an oversight on my part. I'll do that :)

Resolving patch:
diff --git a/strawberry/relay/fields.py b/strawberry/relay/fields.py
--- a/strawberry/relay/fields.py
+++ b/strawberry/relay/fields.py
@@ -72,6 +72,16 @@ def resolve(
     ) -> Any:
         return next_(source, info, **kwargs)
 
+    async def resolve_async(
+        self, next_: SyncExtensionResolver, source: Any, info: Info, **kwargs: Any
+    ) -> Any:
+        retval = next_(source, info, **kwargs)
+        # If the resolve_nodes method is not async, retval will not actually
+        # be awaitable. We still need the `resolve_async` in here because
+        # otherwise this extension can't be used together with other
+        # async extensions.
+        return await retval if inspect.isawaitable(retval) else retval
+
     def get_node_resolver(self, field: StrawberryField):  # noqa: ANN201
         type_ = field.type
         is_optional = isinstance(type_, StrawberryOptional)


Test patch:
diff --git a/tests/relay/schema.gql b/tests/relay/schema.gql
--- a/tests/relay/schema.gql
+++ b/tests/relay/schema.gql
@@ -104,6 +104,10 @@ type Query {
     """The ID of the object."""
     id: GlobalID!
   ): Node!
+  nodeWithAsyncPermissions(
+    """The ID of the object."""
+    id: GlobalID!
+  ): Node!
   nodes(
     """The IDs of the objects."""
     ids: [GlobalID!]!
diff --git a/tests/relay/schema.py b/tests/relay/schema.py
--- a/tests/relay/schema.py
+++ b/tests/relay/schema.py
@@ -16,6 +16,7 @@
 
 import strawberry
 from strawberry import relay
+from strawberry.permission import BasePermission
 from strawberry.relay.utils import to_base64
 from strawberry.types import Info
 
@@ -176,9 +177,19 @@ async def fruits_async_resolver() -> Iterable[FruitAsync]:
     return fruits_async.values()
 
 
+class DummyPermission(BasePermission):
+    message = "Dummy message"
+
+    async def has_permission(self, source: Any, info: Info, **kwargs: Any) -> bool:
+        return True
+
+
 @strawberry.type
 class Query:
     node: relay.Node = relay.node()
+    node_with_async_permissions: relay.Node = relay.node(
+        permission_classes=[DummyPermission]
+    )
     nodes: List[relay.Node] = relay.node()
     node_optional: Optional[relay.Node] = relay.node()
     nodes_optional: List[Optional[relay.Node]] = relay.node()
diff --git a/tests/relay/test_fields.py b/tests/relay/test_fields.py
--- a/tests/relay/test_fields.py
+++ b/tests/relay/test_fields.py
@@ -34,6 +34,35 @@ def test_query_node():
     }
 
 
+async def test_query_node_with_async_permissions():
+    result = await schema.execute(
+        """
+        query TestQuery ($id: GlobalID!) {
+            nodeWithAsyncPermissions (id: $id) {
+                ... on Node {
+                    id
+                }
+                ... on Fruit {
+                    name
+                    color
+                }
+            }
+        }
+        """,
+        variable_values={
+            "id": to_base64("Fruit", 2),
+        },
+    )
+    assert result.errors is None
+    assert result.data == {
+        "nodeWithAsyncPermissions": {
+            "id": to_base64("Fruit", 2),
+            "color": "red",
+            "name": "Apple",
+        },
+    }
+
+
 def test_query_node_optional():
     result = schema.execute_sync(
         """