Repository: strawberry-graphql/strawberry
Pull request #121 (2019-08-02 22:18:01)

Add support for custom directives

codecov[bot]: # [Codecov](https://codecov.io/gh/strawberry-graphql/strawberry/pull/121?src=pr&el=h1) Report
> Merging [#121](https://codecov.io/gh/strawberry-graphql/strawberry/pull/121?src=pr&el=desc) into [master](https://codecov.io/gh/strawberry-graphql/strawberry/commit/4d6b78559bafc9dfa7139f8dca8adbcc8e0a0700?src=pr&el=desc) will **increase** coverage by `0.5%`.
> The diff coverage is `100%`.


```diff
@@            Coverage Diff            @@
##           master     #121     +/-   ##
=========================================
+ Coverage   90.56%   91.07%   +0.5%     
=========================================
  Files          20       22      +2     
  Lines         424      448     +24     
=========================================
+ Hits          384      408     +24     
  Misses         40       40
```


Patch:
diff --git a/RELEASE.md b/RELEASE.md
new file mode 100644
index 00000000..27e7c60a
--- /dev/null
+++ b/RELEASE.md
@@ -0,0 +1,20 @@
+Release type: minor
+
+Added support for defining query directives, example:
+
+```python
+import strawberry
+from strawberry.directives import DirectiveLocation
+
+@strawberry.type
+class Query:
+    cake: str = "made_in_switzerland"
+
+@strawberry.directive(
+    locations=[DirectiveLocation.FIELD], description="Make string uppercase"
+)
+def uppercase(value: str, example: str):
+    return value.upper()
+
+schema = strawberry.Schema(query=Query, directives=[uppercase])
+```
diff --git a/strawberry/__init__.py b/strawberry/__init__.py
index 7220b009..63379c82 100644
--- a/strawberry/__init__.py
+++ b/strawberry/__init__.py
@@ -8,3 +8,4 @@ from .scalars import ID  # noqa
 from .schema import Schema  # noqa
 from .type import input, type, interface  # noqa
 from .permission import BasePermission  # noqa
+from .directive import directive  # noqa
diff --git a/strawberry/asgi/__init__.py b/strawberry/asgi/__init__.py
index dcb1e23c..38ef0fa2 100644
--- a/strawberry/asgi/__init__.py
+++ b/strawberry/asgi/__init__.py
@@ -1,13 +1,13 @@
 import asyncio
 import typing
 
-from graphql import graphql, GraphQLSchema, GraphQLError
+from graphql import GraphQLSchema, GraphQLError
 from starlette.requests import Request
 from starlette.types import Receive, Scope, Send
 from starlette.websockets import WebSocket, WebSocketState
 from graphql.error import format_error as format_graphql_error
 
-from ..graphql import subscribe
+from ..graphql import execute, subscribe
 from ..utils.debug import pretty_print_graphql_operation
 from .constants import (
     GQL_COMPLETE,
@@ -146,7 +146,7 @@ class GraphQL:
         if self.debug:
             pretty_print_graphql_operation(operation_name, query, variables)
 
-        return await graphql(
+        return await execute(
             self.schema,
             query,
             variable_values=variables,
diff --git a/strawberry/directive.py b/strawberry/directive.py
new file mode 100644
index 00000000..e4bda09f
--- /dev/null
+++ b/strawberry/directive.py
@@ -0,0 +1,46 @@
+import typing
+from itertools import islice
+
+from graphql import DirectiveLocation, GraphQLArgument, GraphQLDirective
+
+from .type_converter import get_graphql_type_for_annotation
+from .utils.str_converters import to_camel_case
+
+
+DIRECTIVE_REGISTRY = {}
+
+
+def _get_arguments(func):
+    annotations = func.__annotations__
+    name = func.__name__
+
+    arguments = {}
+
+    for name, type_ in islice(annotations.items(), 1, None):
+        argument_type = get_graphql_type_for_annotation(type_, name)
+
+        name = to_camel_case(name)
+
+        arguments[name] = GraphQLArgument(argument_type)
+
+    return arguments
+
+
+def directive(
+    *, locations: typing.List[DirectiveLocation], description=None, name=None
+):
+    def _wrap(func):
+        directive_name = name or to_camel_case(func.__name__)
+
+        func.directive = GraphQLDirective(
+            name=directive_name,
+            locations=locations,
+            args=_get_arguments(func),
+            description=description,
+        )
+
+        DIRECTIVE_REGISTRY[directive_name] = func
+
+        return func
+
+    return _wrap
diff --git a/strawberry/field.py b/strawberry/field.py
index ddb513ed..80584049 100644
--- a/strawberry/field.py
+++ b/strawberry/field.py
@@ -55,7 +55,7 @@ class LazyFieldWrapper:
 
     def _check_has_annotations(self, func):
         # using annotations without passing from typing.get_type_hints
-        # as we don't the actually types for the annotations
+        # as we don't need the actually types for the annotations
         annotations = func.__annotations__
         name = func.__name__
 
diff --git a/strawberry/graphql.py b/strawberry/graphql.py
index b1969d1f..b76aec95 100644
--- a/strawberry/graphql.py
+++ b/strawberry/graphql.py
@@ -1,7 +1,51 @@
 import typing
 
-from graphql import ExecutionResult, GraphQLSchema, parse
+from graphql import (
+    ExecutionResult,
+    GraphQLError,
+    GraphQLSchema,
+    execute as graphql_excute,
+    parse,
+)
 from graphql.subscription import subscribe as graphql_subscribe
+from graphql.type import validate_schema
+from graphql.validation import validate
+
+from .middleware import DirectivesMiddleware
+
+
+async def execute(
+    schema: GraphQLSchema,
+    query: str,
+    context_value: typing.Any = None,
+    variable_values: typing.Dict[str, typing.Any] = None,
+    operation_name: str = None,
+):
+    schema_validation_errors = validate_schema(schema)
+    if schema_validation_errors:
+        return ExecutionResult(data=None, errors=schema_validation_errors)
+
+    try:
+        document = parse(query)
+    except GraphQLError as error:
+        return ExecutionResult(data=None, errors=[error])
+    except Exception as error:
+        error = GraphQLError(str(error), original_error=error)
+        return ExecutionResult(data=None, errors=[error])
+
+    validation_errors = validate(schema, document)
+
+    if validation_errors:
+        return ExecutionResult(data=None, errors=validation_errors)
+
+    return graphql_excute(
+        schema,
+        parse(query),
+        middleware=[DirectivesMiddleware()],
+        variable_values=variable_values,
+        operation_name=operation_name,
+        context_value=context_value,
+    )
 
 
 async def subscribe(
diff --git a/strawberry/middleware.py b/strawberry/middleware.py
new file mode 100644
index 00000000..cf357425
--- /dev/null
+++ b/strawberry/middleware.py
@@ -0,0 +1,20 @@
+from .directive import DIRECTIVE_REGISTRY
+
+
+class DirectivesMiddleware:
+    def resolve(self, next_, root, info, **kwargs):
+        result = next_(root, info, **kwargs)
+
+        for directive in info.field_nodes[0].directives:
+            directive_name = directive.name.value
+
+            func = DIRECTIVE_REGISTRY.get(directive_name)
+
+            arguments = {
+                argument.name.value: argument.value.value
+                for argument in directive.arguments
+            }
+
+            result = func(result, **arguments)
+
+        return result
diff --git a/strawberry/schema.py b/strawberry/schema.py
index a30c2ba7..400ebb3f 100644
--- a/strawberry/schema.py
+++ b/strawberry/schema.py
@@ -1,13 +1,20 @@
 from graphql import GraphQLSchema
+from graphql.type.directives import specified_directives
+from graphql.utilities.schema_printer import print_schema
 
 
 # TODO: typings
 
 
 class Schema(GraphQLSchema):
-    def __init__(self, query, mutation=None, subscription=None):
+    def __init__(self, query, mutation=None, subscription=None, directives=[]):
         super().__init__(
             query=query.field,
             mutation=mutation.field if mutation else None,
             subscription=subscription.field if subscription else None,
+            directives=specified_directives
+            + [directive.directive for directive in directives],
         )
+
+    def __repr__(self):
+        return print_schema(self)


Test patch:
diff --git a/tests/test_directives.py b/tests/test_directives.py
new file mode 100644
index 00000000..29b76693
--- /dev/null
+++ b/tests/test_directives.py
@@ -0,0 +1,14 @@
+import strawberry
+from graphql import DirectiveLocation, GraphQLDirective
+
+
+def test_declaring_executable_directive():
+    @strawberry.directive(
+        locations=[DirectiveLocation.FIELD], description="Sample description"
+    )
+    def uppercase(value: str) -> str:
+        return value.upper()
+
+    assert uppercase.directive.__class__ == GraphQLDirective
+    assert uppercase.directive.name == "uppercase"
+    assert uppercase.directive.description == "Sample description"
diff --git a/tests/test_execute.py b/tests/test_execute.py
new file mode 100644
index 00000000..8735222b
--- /dev/null
+++ b/tests/test_execute.py
@@ -0,0 +1,35 @@
+import pytest
+
+import strawberry
+from graphql import DirectiveLocation
+from strawberry.graphql import execute
+
+
+@pytest.mark.asyncio
+async def test_runs_directives():
+    @strawberry.type
+    class Query:
+        name: str = "Jess"
+
+    @strawberry.directive(
+        locations=[DirectiveLocation.FIELD], description="Make string uppercase"
+    )
+    def uppercase(value: str):
+        return value.upper()
+
+    @strawberry.directive(locations=[DirectiveLocation.FIELD])
+    def replace(value: str, old: str, new: str):
+        return value.replace(old, new)
+
+    schema = strawberry.Schema(query=Query, directives=[uppercase, replace])
+
+    query = """{
+        name @uppercase
+        jess: name @replace(old: "Jess", new: "Jessica")
+    }"""
+
+    result = await execute(schema, query)
+
+    assert not result.errors
+    assert result.data["name"] == "JESS"
+    assert result.data["jess"] == "Jessica"
diff --git a/tests/test_schema.py b/tests/test_schema.py
index bb0741e4..4e26d45d 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -1,3 +1,4 @@
+import textwrap
 import typing
 from enum import Enum
 
@@ -5,7 +6,7 @@ import pytest
 
 import strawberry
 from dataclasses import InitVar
-from graphql import graphql, graphql_sync
+from graphql import DirectiveLocation, graphql, graphql_sync
 
 
 def test_simple_type():
@@ -435,3 +436,28 @@ def test_parent_class_fields_are_inherited():
     assert result.data["cake"] == "made_in_switzerland"
     assert result.data["friend"] == "food"
     assert result.data["helloThisIs"] == "patrick"
+
+
+def test_can_declare_directives():
+    @strawberry.type
+    class Query:
+        cake: str = "made_in_switzerland"
+
+    @strawberry.directive(
+        locations=[DirectiveLocation.FIELD], description="Make string uppercase"
+    )
+    def uppercase(value: str, example: str):
+        return value.upper()
+
+    schema = strawberry.Schema(query=Query, directives=[uppercase])
+
+    expected_schema = '''
+    """Make string uppercase"""
+    directive @uppercase(example: String!) on FIELD
+
+    type Query {
+      cake: String!
+    }
+    '''
+
+    assert repr(schema) == textwrap.dedent(expected_schema).lstrip()