Skip to content

Commit aae1d69

Browse files
authored
Merge pull request #185 from graphql-python/document
Added support for pluggable backends
2 parents 2692232 + c88fb21 commit aae1d69

18 files changed

+874
-335
lines changed

graphql/__init__.py

+17
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

+38
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

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

graphql/backend/cache.py

+61
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

+37
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

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

graphql/backend/decider.py

+24
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:
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

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
try:
2+
import requests
3+
except ImportError:
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 Exception:
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+
97+
def uptodate():
98+
return True
99+
100+
document = GraphQLCompiledDocument.from_code(
101+
schema, code, uptodate, self.extra_namespace
102+
)
103+
return document

graphql/backend/tests/__init__.py

Whitespace-only changes.

graphql/backend/tests/schema.py

+9
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

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

0 commit comments

Comments
 (0)