Skip to content

Commit fd76799

Browse files
committed
Added support for dynamic backends
1 parent f6d79ab commit fd76799

14 files changed

+510
-20
lines changed

graphql/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,16 @@
193193
Undefined,
194194
)
195195

196+
# Utilities for dynamic execution engines
197+
from .backend import (
198+
GraphQLBackend,
199+
GraphQLDocument,
200+
GraphQLCoreBackend,
201+
GraphQLDeciderBackend,
202+
GraphQLCachedBackend,
203+
get_default_backend,
204+
set_default_backend,
205+
)
196206

197207
VERSION = (2, 0, 1, 'final', 0)
198208
__version__ = get_version(VERSION)
@@ -282,4 +292,11 @@
282292
'value_from_ast',
283293
'get_version',
284294
'Undefined',
295+
'GraphQLBackend',
296+
'GraphQLDocument',
297+
'GraphQLCoreBackend',
298+
'GraphQLDeciderBackend',
299+
'GraphQLCachedBackend',
300+
'get_default_backend',
301+
'set_default_backend',
285302
)

graphql/backend/__init__.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
This module provides a dynamic way of using different
4+
engines for a GraphQL schema query resolution.
5+
"""
6+
7+
from .base import GraphQLBackend, GraphQLDocument
8+
from .core import GraphQLCoreBackend
9+
from .decider import GraphQLDeciderBackend
10+
from .cache import GraphQLCachedBackend
11+
12+
_default_backend = None
13+
14+
15+
def get_default_backend():
16+
global _default_backend
17+
if _default_backend is None:
18+
_default_backend = GraphQLCoreBackend()
19+
return _default_backend
20+
21+
22+
def set_default_backend(backend):
23+
global _default_backend
24+
assert isinstance(
25+
backend, GraphQLBackend
26+
), "backend must be an instance of GraphQLBackend."
27+
_default_backend = backend
28+
29+
30+
__all__ = [
31+
"GraphQLBackend",
32+
"GraphQLDocument",
33+
"GraphQLCoreBackend",
34+
"GraphQLDeciderBackend",
35+
"GraphQLCachedBackend",
36+
"get_default_backend",
37+
"set_default_backend",
38+
]

graphql/backend/base.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from ..language import ast
2+
from abc import ABCMeta, abstractmethod
3+
import six
4+
5+
6+
7+
class GraphQLBackend(six.with_metaclass(ABCMeta)):
8+
@abstractmethod
9+
def document_from_string(self, schema, request_string):
10+
raise NotImplementedError(
11+
"document_from_string method not implemented in {}.".format(self.__class__)
12+
)
13+
14+
15+
class GraphQLDocument(object):
16+
def __init__(self, schema, document_string, document_ast, execute):
17+
self.schema = schema
18+
self.document_string = document_string
19+
self.document_ast = document_ast
20+
self.execute = execute
21+
22+
def get_operations(self):
23+
document_ast = self.document_ast
24+
operations = {}
25+
for definition in document_ast.definitions:
26+
if isinstance(definition, ast.OperationDefinition):
27+
if definition.name:
28+
operation_name = definition.name.value
29+
else:
30+
operation_name = None
31+
operations[operation_name] = definition.operation
32+
return operations

graphql/backend/cache.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from hashlib import sha1
2+
from six import string_types
3+
from ..type import GraphQLSchema
4+
5+
from .base import GraphQLBackend
6+
7+
_cached_schemas = {}
8+
9+
_cached_queries = {}
10+
11+
12+
def get_unique_schema_id(schema):
13+
"""Get a unique id given a GraphQLSchema"""
14+
assert isinstance(schema, GraphQLSchema), (
15+
"Must receive a GraphQLSchema as schema. Received {}"
16+
).format(repr(schema))
17+
18+
if schema not in _cached_schemas:
19+
_cached_schemas[schema] = sha1(str(schema).encode("utf-8")).hexdigest()
20+
return _cached_schemas[schema]
21+
22+
23+
def get_unique_document_id(query_str):
24+
"""Get a unique id given a query_string"""
25+
assert isinstance(query_str, string_types), (
26+
"Must receive a string as query_str. Received {}"
27+
).format(repr(query_str))
28+
29+
if query_str not in _cached_queries:
30+
_cached_queries[query_str] = sha1(str(query_str).encode("utf-8")).hexdigest()
31+
return _cached_queries[query_str]
32+
33+
34+
class GraphQLCachedBackend(GraphQLBackend):
35+
def __init__(self, backend, cache_map=None, use_consistent_hash=False):
36+
assert isinstance(
37+
backend, GraphQLBackend
38+
), "Provided backend must be an instance of GraphQLBackend"
39+
if cache_map is None:
40+
cache_map = {}
41+
self.backend = backend
42+
self.cache_map = cache_map
43+
self.use_consistent_hash = use_consistent_hash
44+
45+
def get_key_for_schema_and_document_string(self, schema, request_string):
46+
"""This method returns a unique key given a schema and a request_string"""
47+
if self.use_consistent_hash:
48+
schema_id = get_unique_schema_id(schema)
49+
document_id = get_unique_document_id(request_string)
50+
return (schema_id, document_id)
51+
return hash((schema, request_string))
52+
53+
def document_from_string(self, schema, request_string):
54+
"""This method returns a GraphQLQuery (from cache if present)"""
55+
key = self.get_key_for_schema_and_document_string(schema, request_string)
56+
if key not in self.cache_map:
57+
self.cache_map[key] = self.backend.document_from_string(
58+
schema, request_string
59+
)
60+
61+
return self.cache_map[key]

graphql/backend/compiled.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from .base import GraphQLDocument
2+
3+
4+
class GraphQLCompiledDocument(GraphQLDocument):
5+
@classmethod
6+
def from_code(cls, schema, code, uptodate=None, extra_namespace=None):
7+
"""Creates a GraphQLDocument object from compiled code and the globals. This
8+
is used by the loaders and schema to create a document object.
9+
"""
10+
namespace = {"__file__": code.co_filename}
11+
exec(code, namespace)
12+
if extra_namespace:
13+
namespace.update(extra_namespace)
14+
rv = cls._from_namespace(schema, namespace)
15+
rv._uptodate = uptodate
16+
return rv
17+
18+
@classmethod
19+
def from_module_dict(cls, schema, module_dict):
20+
"""Creates a template object from a module. This is used by the
21+
module loader to create a document object.
22+
"""
23+
return cls._from_namespace(schema, module_dict)
24+
25+
@classmethod
26+
def _from_namespace(cls, schema, namespace):
27+
document_string = namespace.get("document_string", "")
28+
document_ast = namespace.get("document_ast")
29+
execute = namespace["execute"]
30+
31+
namespace["schema"] = schema
32+
return cls(
33+
schema=schema,
34+
document_string=document_string,
35+
document_ast=document_ast,
36+
execute=execute,
37+
)

graphql/backend/core.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from functools import partial
2+
from six import string_types
3+
4+
from ..execution import execute, ExecutionResult
5+
from ..language.base import parse, print_ast
6+
from ..language import ast
7+
from ..validation import validate
8+
from ..error import GraphQLError
9+
10+
from .base import GraphQLBackend, GraphQLDocument
11+
12+
13+
def execute_and_validate(schema, document_ast, *args, **kwargs):
14+
do_validation = kwargs.get('validate', True)
15+
if do_validation:
16+
validation_errors = validate(schema, document_ast)
17+
if validation_errors:
18+
return ExecutionResult(
19+
errors=validation_errors,
20+
invalid=True,
21+
)
22+
23+
return execute(schema, document_ast, *args, **kwargs)
24+
25+
26+
class GraphQLCoreBackend(GraphQLBackend):
27+
def __init__(self, executor=None, **kwargs):
28+
super(GraphQLCoreBackend, self).__init__(**kwargs)
29+
self.execute_params = {"executor": executor}
30+
31+
def document_from_string(self, schema, document_string):
32+
if isinstance(document_string, ast.Document):
33+
document_ast = document_string
34+
document_string = print_ast(document_ast)
35+
else:
36+
assert isinstance(document_string, string_types), "The query must be a string"
37+
document_ast = parse(document_string)
38+
return GraphQLDocument(
39+
schema=schema,
40+
document_string=document_string,
41+
document_ast=document_ast,
42+
execute=partial(execute_and_validate, schema, document_ast, **self.execute_params),
43+
)

graphql/backend/decider.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from .base import GraphQLBackend
2+
3+
4+
class GraphQLDeciderBackend(GraphQLBackend):
5+
def __init__(self, backends=None):
6+
if not backends:
7+
raise Exception("Need to provide backends to decide into.")
8+
if not isinstance(backends, (list, tuple)):
9+
raise Exception("Provided backends need to be a list or tuple.")
10+
self.backends = backends
11+
super(GraphQLDeciderBackend, self).__init__()
12+
13+
def document_from_string(self, schema, request_string):
14+
for backend in self.backends:
15+
try:
16+
return backend.document_from_string(schema, request_string)
17+
except Exception as e:
18+
continue
19+
20+
raise Exception(
21+
"GraphQLDeciderBackend was not able to retrieve a document. Backends tried: {}".format(
22+
repr(self.backends)
23+
)
24+
)

graphql/backend/quiver_cloud.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
try:
2+
import requests
3+
except:
4+
raise ImportError(
5+
"requests package is required for Quiver Cloud backend.\n"
6+
"You can install it using: pip install requests"
7+
)
8+
9+
from ..utils.schema_printer import print_schema
10+
11+
from .base import GraphQLBackend
12+
from .compiled import GraphQLCompiledDocument
13+
14+
from six import urlparse
15+
16+
GRAPHQL_QUERY = """
17+
mutation($schemaDsl: String!, $query: String!) {
18+
generateCode(
19+
schemaDsl: $schemaDsl
20+
query: $query,
21+
language: PYTHON,
22+
pythonOptions: {
23+
asyncFramework: PROMISE
24+
}
25+
) {
26+
code
27+
compilationTime
28+
errors {
29+
type
30+
}
31+
}
32+
}
33+
"""
34+
35+
36+
class GraphQLQuiverCloudBackend(GraphQLBackend):
37+
def __init__(self, dsn, python_options=None, **options):
38+
super(GraphQLQuiverCloudBackend, self).__init__(**options)
39+
try:
40+
url = urlparse(dsn.strip())
41+
except:
42+
raise Exception("Received wrong url {}".format(dsn))
43+
44+
netloc = url.hostname
45+
if url.port:
46+
netloc += ":%s" % url.port
47+
48+
path_bits = url.path.rsplit("/", 1)
49+
if len(path_bits) > 1:
50+
path = path_bits[0]
51+
else:
52+
path = ""
53+
54+
self.api_url = "%s://%s%s" % (url.scheme.rsplit("+", 1)[-1], netloc, path)
55+
self.public_key = url.username
56+
self.secret_key = url.password
57+
self.extra_namespace = {}
58+
if python_options is None:
59+
python_options = {}
60+
wait_for_promises = python_options.pop("wait_for_promises", None)
61+
if wait_for_promises:
62+
assert callable(wait_for_promises), "wait_for_promises must be callable."
63+
self.extra_namespace["wait_for_promises"] = wait_for_promises
64+
self.python_options = python_options
65+
66+
def make_post_request(self, url, auth, json_payload):
67+
"""This function executes the request with the provided
68+
json payload and return the json response"""
69+
response = requests.post(url, auth=auth, json=json_payload)
70+
return response.json()
71+
72+
def generate_source(self, schema, query):
73+
variables = {"schemaDsl": print_schema(schema), "query": query}
74+
75+
json_response = self.make_post_request(
76+
"{}/graphql".format(self.api_url),
77+
auth=(self.public_key, self.secret_key),
78+
json_payload={"query": GRAPHQL_QUERY, "variables": variables},
79+
)
80+
81+
errors = json_response.get('errors')
82+
if errors:
83+
raise Exception(errors[0].get('message'))
84+
data = json_response.get("data", {})
85+
code_generation = data.get("generateCode", {})
86+
code = code_generation.get("code")
87+
if not code:
88+
raise Exception("Cant get the code. Received json from Quiver Cloud")
89+
code = str(code)
90+
return code
91+
92+
def document_from_string(self, schema, request_string):
93+
source = self.generate_source(schema, request_string)
94+
filename = "<document>"
95+
code = compile(source, filename, "exec")
96+
uptodate = lambda: True
97+
document = GraphQLCompiledDocument.from_code(
98+
schema, code, uptodate, self.extra_namespace
99+
)
100+
return document

graphql/backend/tests/__init__.py

Whitespace-only changes.

graphql/backend/tests/schema.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from graphql.type import (GraphQLField, GraphQLObjectType,
2+
GraphQLSchema, GraphQLString)
3+
4+
5+
Query = GraphQLObjectType('Query', lambda: {
6+
'hello': GraphQLField(GraphQLString, resolver=lambda *_: "World"),
7+
})
8+
9+
schema = GraphQLSchema(Query)

graphql/backend/tests/test_cache.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
"""Tests for `graphql.backend.cache` module."""
4+
5+
import pytest
6+
7+
from ..base import GraphQLCoreBackend, GraphQLCachedBackend
8+
from graphql.execution.executors.sync import SyncExecutor
9+
from .schema import schema
10+
11+
12+
def test_backend_is_cached_when_needed():
13+
cached_backend = GraphQLCachedBackend(GraphQLCoreBackend())
14+
document1 = cached_backend.document_from_string(schema, "{ hello }")
15+
document2 = cached_backend.document_from_string(schema, "{ hello }")
16+
assert document1 == document2

0 commit comments

Comments
 (0)