Skip to content

Commit

Permalink
GraphQL extensions (#24)
Browse files Browse the repository at this point in the history
* use GraphQLBackend for document parsing

* first version of graphql-extensions; apollo engine reporting

* skip integration test

* remove use of deprecated variable names

* use tox

* minor fixes for tests

* add clarfication on extensions

* remove apollo tracing from branch
  • Loading branch information
ewhauser authored Aug 27, 2019
1 parent af98c6a commit a2ca389
Show file tree
Hide file tree
Showing 9 changed files with 394 additions and 41 deletions.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@
build
dist
graphene_tornado.egg-info/

apollo.config.js
schema.graphql
.env
.tox
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,9 @@ async def resolve_foo(self, info):
foo = await db.get_foo()
return foo
```

# Extensions

`graphene-tornado` supports server-side extensions like [Apollo Server](https://www.apollographql.com/docs/apollo-server/features/metrics). The extensions go a step further than Graphene middleware to allow for finer grained interception of request processing. The canonical use case is for tracing.

Extensions are experimental and most likely will change in future releases as they should be extensions provided by `graphql-core`.
1 change: 1 addition & 0 deletions examples/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def __init__(self):
]
tornado.web.Application.__init__(self, handlers)


if __name__ == '__main__':
app = ExampleApplication()
app.listen(5000)
Expand Down
2 changes: 1 addition & 1 deletion graphene_tornado/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = '2.1.1'
__version__ = '2.2'

__all__ = [
'__version__'
Expand Down
86 changes: 86 additions & 0 deletions graphene_tornado/extension_stack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""
ExtensionStack is an adapter for GraphQLExtension that helps invoke a list of GraphQLExtension objects at runtime.
"""

from functools import partial

from tornado.gen import coroutine, Return
from typing import List

from graphene_tornado.graphql_extension import GraphQLExtension


class GraphQLExtensionStack(GraphQLExtension):

def __init__(self, extensions=None):
self.extensions = extensions or [] # type: List[GraphQLExtension]

@coroutine
def request_started(self, request, query_string, parsed_query, operation_name, variables, context, request_context):
on_end = yield self._handle_did_start('request_started', request, query_string, parsed_query, operation_name,
variables, context, request_context)
raise Return(on_end)

@coroutine
def parsing_started(self, query_string):
on_end = yield self._handle_did_start('parsing_started', query_string)
raise Return(on_end)

@coroutine
def validation_started(self):
on_end = yield self._handle_did_start('validation_started')
raise Return(on_end)

@coroutine
def execution_started(self, schema, document, root, context, variables, operation_name):
on_end = yield self._handle_did_start('execution_started', schema, document, root, context,
variables, operation_name)
raise Return(on_end)

@coroutine
def will_resolve_field(self, next, root, info, **args):
ext = self.extensions[:]
ext.reverse()

handlers = []
for extension in self.extensions:
handlers.append(extension.will_resolve_field(next, root, info, **args))

@coroutine
def on_end(error=None, result=None):
for handler in handlers:
yield handler(error, result)

raise Return(on_end)

@coroutine
def will_send_response(self, response, context):
ref = [response, context]
ext = self.extensions[:]
ext.reverse()

for handler in ext:
result = yield handler.will_send_response(ref[0], ref[1])
if result:
ref = [result, context]
raise Return(ref)

@coroutine
def _handle_did_start(self, method, *args):
end_handlers = []
for extension in self.extensions:
invoker = partial(getattr(extension, method), *args)
end_handler = invoker()
if end_handler:
end_handlers.append(end_handler)

@coroutine
def end(errors=None):
errors = errors or []
end_handlers.reverse()
for handler_future in end_handlers:
handler = yield handler_future
if handler:
yield handler(errors)

raise Return(end)
89 changes: 89 additions & 0 deletions graphene_tornado/graphql_extension.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""
GraphQLExtension is analogous to the server extensions that are provided
by Apollo Server: https://github.com/apollographql/apollo-server/tree/master/packages/graphql-extensions
Extensions are also middleware but have additional hooks.
"""
from __future__ import absolute_import, print_function

import sys
from abc import ABCMeta, abstractmethod

from tornado.gen import coroutine
from typing import NewType, List, Callable, Optional

EndHandler = NewType('EndHandler', Optional[List[Callable[[List[Exception]], None]]])


class GraphQLExtension:

__metaclass__ = ABCMeta

@abstractmethod
def request_started(self,
request, # type: HTTPServerRequest
query_string, # type: Optional[str],
parsed_query, # type: Optional[Document]
operation_name, # type: Optional[str]
variables, # type: Optional[dict[str, Any]]
context, # type: Any
request_context # type: Any
):
# type: (...) -> EndHandler
pass

@abstractmethod
def parsing_started(self, query_string): # type: (str) -> EndHandler
pass

@abstractmethod
def validation_started(self):
# type: () -> EndHandler
pass

@abstractmethod
def execution_started(self,
schema, # type: GraphQLSchema
document, # type: Document
root, # type: Any
context, # type: Optional[Any]
variables, # type: Optional[Any]
operation_name # type: Optional[str]
):
# type: (...) -> EndHandler
pass

@abstractmethod
def will_resolve_field(self, next, root, info, **args):
# type: (...) -> EndHandler
pass

@abstractmethod
def will_send_response(self,
response, # type: Any
context, # type: Any
):
# type: (...) -> EndHandler
pass

def as_middleware(self):
# type: () -> Callable
"""
Adapter for using the stack as middleware so that the will_resolve_field function
is invoked like normal middleware
Returns:
An adapter function
"""
@coroutine
def middleware(next, root, info, **args):
end_resolve = yield self.will_resolve_field(next, root, info, **args)
errors = []
result = None
try:
result = next(root, info, **args)
except:
errors.append(sys.exc_info()[0])
finally:
end_resolve(errors, result)
return middleware
27 changes: 27 additions & 0 deletions graphene_tornado/schema_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import absolute_import, print_function

import hashlib

from graphene import Schema
from graphql import parse, execute, GraphQLError
from graphql.utils.introspection_query import introspection_query
from json_stable_stringify_python import stringify


def generate_schema_hash(schema):
# type: (Schema) -> str
"""
Generates a stable hash of the current schema using an introspection query.
"""
ast = parse(introspection_query)
result = execute(schema, ast)

if result and not result.data:
raise GraphQLError('Unable to generate server introspection document')

schema = result.data['__schema']
# It's important that we perform a deterministic stringification here
# since, depending on changes in the underlying `graphql-core` execution
# layer, varying orders of the properties in the introspection
stringified_schema = stringify(schema).encode('utf-8')
return hashlib.sha512(stringified_schema).hexdigest()
96 changes: 96 additions & 0 deletions graphene_tornado/tests/test_extensions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import json
from functools import partial

import pytest
import tornado
from tornado.gen import coroutine, Return

from graphene_tornado.schema import schema
from graphene_tornado.graphql_extension import GraphQLExtension
from graphene_tornado.tests.http_helper import HttpHelper
from graphene_tornado.tests.test_graphql import url_string, GRAPHQL_HEADER, response_json
from graphene_tornado.tornado_graphql_handler import TornadoGraphQLHandler


STARTED = []
ENDED = []


def _track_closed(name, errors=None):
ENDED.append(name)


class TrackingExtension(GraphQLExtension):

@coroutine
def request_started(self, request, query_string, parsed_query, operation_name, variables, context, request_context):
phase = 'request'
STARTED.append(phase)
raise Return(partial(_track_closed, phase))

@coroutine
def parsing_started(self, query_string):
phase = 'parsing'
STARTED.append(phase)
raise Return(partial(_track_closed, phase))

@coroutine
def validation_started(self):
phase = 'validation'
STARTED.append(phase)
raise Return(partial(_track_closed, phase))

@coroutine
def execution_started(self, schema, document, root, context, variables, operation_name):
phase = 'execution'
STARTED.append(phase)
raise Return(partial(_track_closed, phase))

@coroutine
def will_resolve_field(self, next, root, info, **args):
phase = 'resolve_field'
STARTED.append(phase)
raise Return(partial(_track_closed, phase))

@coroutine
def will_send_response(self, response, context):
phase = 'response'
STARTED.append(phase)


class ExampleExtensionsApplication(tornado.web.Application):

def __init__(self):
handlers = [
(r'/graphql', TornadoGraphQLHandler, dict(graphiql=True, schema=schema, extensions=[TrackingExtension()])),
(r'/graphql/batch', TornadoGraphQLHandler, dict(graphiql=True, schema=schema, batch=True)),
(r'/graphql/graphiql', TornadoGraphQLHandler, dict(graphiql=True, schema=schema))
]
tornado.web.Application.__init__(self, handlers)


@pytest.fixture
def app():
return ExampleExtensionsApplication()


@pytest.fixture
def http_helper(http_client, base_url):
return HttpHelper(http_client, base_url)


@pytest.mark.gen_test
def test_extensions_are_called_in_order(http_helper):
response = yield http_helper.get(url_string(
query='query helloWho($who: String){ test(who: $who) }',
variables=json.dumps({'who': "Dolly"})
), headers=GRAPHQL_HEADER)

assert response.code == 200
assert response_json(response) == {
'data': {'test': "Hello Dolly"}
}

assert ['request', 'parsing', 'validation', 'execution', 'response'] == STARTED
assert ['parsing', 'validation', 'execution', 'request'] == ENDED

Loading

0 comments on commit a2ca389

Please sign in to comment.