Skip to content

Added support for pluggable backends #185

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 5, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions graphql/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,16 @@
Undefined,
)

# Utilities for dynamic execution engines
from .backend import (
GraphQLBackend,
GraphQLDocument,
GraphQLCoreBackend,
GraphQLDeciderBackend,
GraphQLCachedBackend,
get_default_backend,
set_default_backend,
)

VERSION = (2, 0, 1, 'final', 0)
__version__ = get_version(VERSION)
Expand Down Expand Up @@ -282,4 +292,11 @@
'value_from_ast',
'get_version',
'Undefined',
'GraphQLBackend',
'GraphQLDocument',
'GraphQLCoreBackend',
'GraphQLDeciderBackend',
'GraphQLCachedBackend',
'get_default_backend',
'set_default_backend',
)
38 changes: 38 additions & 0 deletions graphql/backend/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""
This module provides a dynamic way of using different
engines for a GraphQL schema query resolution.
"""

from .base import GraphQLBackend, GraphQLDocument
from .core import GraphQLCoreBackend
from .decider import GraphQLDeciderBackend
from .cache import GraphQLCachedBackend

_default_backend = None


def get_default_backend():
global _default_backend
if _default_backend is None:
_default_backend = GraphQLCoreBackend()
return _default_backend


def set_default_backend(backend):
global _default_backend
assert isinstance(
backend, GraphQLBackend
), "backend must be an instance of GraphQLBackend."
_default_backend = backend


__all__ = [
"GraphQLBackend",
"GraphQLDocument",
"GraphQLCoreBackend",
"GraphQLDeciderBackend",
"GraphQLCachedBackend",
"get_default_backend",
"set_default_backend",
]
31 changes: 31 additions & 0 deletions graphql/backend/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from ..language import ast
from abc import ABCMeta, abstractmethod
import six


class GraphQLBackend(six.with_metaclass(ABCMeta)):
@abstractmethod
def document_from_string(self, schema, request_string):
raise NotImplementedError(
"document_from_string method not implemented in {}.".format(self.__class__)
)


class GraphQLDocument(object):
def __init__(self, schema, document_string, document_ast, execute):
self.schema = schema
self.document_string = document_string
self.document_ast = document_ast
self.execute = execute

def get_operations(self):
document_ast = self.document_ast
operations = {}
for definition in document_ast.definitions:
if isinstance(definition, ast.OperationDefinition):
if definition.name:
operation_name = definition.name.value
else:
operation_name = None
operations[operation_name] = definition.operation
return operations
61 changes: 61 additions & 0 deletions graphql/backend/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from hashlib import sha1
from six import string_types
from ..type import GraphQLSchema

from .base import GraphQLBackend

_cached_schemas = {}

_cached_queries = {}


def get_unique_schema_id(schema):
"""Get a unique id given a GraphQLSchema"""
assert isinstance(schema, GraphQLSchema), (
"Must receive a GraphQLSchema as schema. Received {}"
).format(repr(schema))

if schema not in _cached_schemas:
_cached_schemas[schema] = sha1(str(schema).encode("utf-8")).hexdigest()
return _cached_schemas[schema]


def get_unique_document_id(query_str):
"""Get a unique id given a query_string"""
assert isinstance(query_str, string_types), (
"Must receive a string as query_str. Received {}"
).format(repr(query_str))

if query_str not in _cached_queries:
_cached_queries[query_str] = sha1(str(query_str).encode("utf-8")).hexdigest()
return _cached_queries[query_str]


class GraphQLCachedBackend(GraphQLBackend):
def __init__(self, backend, cache_map=None, use_consistent_hash=False):
assert isinstance(
backend, GraphQLBackend
), "Provided backend must be an instance of GraphQLBackend"
if cache_map is None:
cache_map = {}
self.backend = backend
self.cache_map = cache_map
self.use_consistent_hash = use_consistent_hash

def get_key_for_schema_and_document_string(self, schema, request_string):
"""This method returns a unique key given a schema and a request_string"""
if self.use_consistent_hash:
schema_id = get_unique_schema_id(schema)
document_id = get_unique_document_id(request_string)
return (schema_id, document_id)
return hash((schema, request_string))

def document_from_string(self, schema, request_string):
"""This method returns a GraphQLQuery (from cache if present)"""
key = self.get_key_for_schema_and_document_string(schema, request_string)
if key not in self.cache_map:
self.cache_map[key] = self.backend.document_from_string(
schema, request_string
)

return self.cache_map[key]
37 changes: 37 additions & 0 deletions graphql/backend/compiled.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from .base import GraphQLDocument


class GraphQLCompiledDocument(GraphQLDocument):
@classmethod
def from_code(cls, schema, code, uptodate=None, extra_namespace=None):
"""Creates a GraphQLDocument object from compiled code and the globals. This
is used by the loaders and schema to create a document object.
"""
namespace = {"__file__": code.co_filename}
exec(code, namespace)
if extra_namespace:
namespace.update(extra_namespace)
rv = cls._from_namespace(schema, namespace)
rv._uptodate = uptodate
return rv

@classmethod
def from_module_dict(cls, schema, module_dict):
"""Creates a template object from a module. This is used by the
module loader to create a document object.
"""
return cls._from_namespace(schema, module_dict)

@classmethod
def _from_namespace(cls, schema, namespace):
document_string = namespace.get("document_string", "")
document_ast = namespace.get("document_ast")
execute = namespace["execute"]

namespace["schema"] = schema
return cls(
schema=schema,
document_string=document_string,
document_ast=document_ast,
execute=execute,
)
42 changes: 42 additions & 0 deletions graphql/backend/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from functools import partial
from six import string_types

from ..execution import execute, ExecutionResult
from ..language.base import parse, print_ast
from ..language import ast
from ..validation import validate

from .base import GraphQLBackend, GraphQLDocument


def execute_and_validate(schema, document_ast, *args, **kwargs):
do_validation = kwargs.get('validate', True)
if do_validation:
validation_errors = validate(schema, document_ast)
if validation_errors:
return ExecutionResult(
errors=validation_errors,
invalid=True,
)

return execute(schema, document_ast, *args, **kwargs)


class GraphQLCoreBackend(GraphQLBackend):
def __init__(self, executor=None, **kwargs):
super(GraphQLCoreBackend, self).__init__(**kwargs)
self.execute_params = {"executor": executor}

def document_from_string(self, schema, document_string):
if isinstance(document_string, ast.Document):
document_ast = document_string
document_string = print_ast(document_ast)
else:
assert isinstance(document_string, string_types), "The query must be a string"
document_ast = parse(document_string)
return GraphQLDocument(
schema=schema,
document_string=document_string,
document_ast=document_ast,
execute=partial(execute_and_validate, schema, document_ast, **self.execute_params),
)
24 changes: 24 additions & 0 deletions graphql/backend/decider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from .base import GraphQLBackend


class GraphQLDeciderBackend(GraphQLBackend):
def __init__(self, backends=None):
if not backends:
raise Exception("Need to provide backends to decide into.")
if not isinstance(backends, (list, tuple)):
raise Exception("Provided backends need to be a list or tuple.")
self.backends = backends
super(GraphQLDeciderBackend, self).__init__()

def document_from_string(self, schema, request_string):
for backend in self.backends:
try:
return backend.document_from_string(schema, request_string)
except Exception:
continue

raise Exception(
"GraphQLDeciderBackend was not able to retrieve a document. Backends tried: {}".format(
repr(self.backends)
)
)
103 changes: 103 additions & 0 deletions graphql/backend/quiver_cloud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
try:
import requests
except ImportError:
raise ImportError(
"requests package is required for Quiver Cloud backend.\n"
"You can install it using: pip install requests"
)

from ..utils.schema_printer import print_schema

from .base import GraphQLBackend
from .compiled import GraphQLCompiledDocument

from six import urlparse

GRAPHQL_QUERY = """
mutation($schemaDsl: String!, $query: String!) {
generateCode(
schemaDsl: $schemaDsl
query: $query,
language: PYTHON,
pythonOptions: {
asyncFramework: PROMISE
}
) {
code
compilationTime
errors {
type
}
}
}
"""


class GraphQLQuiverCloudBackend(GraphQLBackend):
def __init__(self, dsn, python_options=None, **options):
super(GraphQLQuiverCloudBackend, self).__init__(**options)
try:
url = urlparse(dsn.strip())
except Exception:
raise Exception("Received wrong url {}".format(dsn))

netloc = url.hostname
if url.port:
netloc += ":%s" % url.port

path_bits = url.path.rsplit("/", 1)
if len(path_bits) > 1:
path = path_bits[0]
else:
path = ""

self.api_url = "%s://%s%s" % (url.scheme.rsplit("+", 1)[-1], netloc, path)
self.public_key = url.username
self.secret_key = url.password
self.extra_namespace = {}
if python_options is None:
python_options = {}
wait_for_promises = python_options.pop("wait_for_promises", None)
if wait_for_promises:
assert callable(wait_for_promises), "wait_for_promises must be callable."
self.extra_namespace["wait_for_promises"] = wait_for_promises
self.python_options = python_options

def make_post_request(self, url, auth, json_payload):
"""This function executes the request with the provided
json payload and return the json response"""
response = requests.post(url, auth=auth, json=json_payload)
return response.json()

def generate_source(self, schema, query):
variables = {"schemaDsl": print_schema(schema), "query": query}

json_response = self.make_post_request(
"{}/graphql".format(self.api_url),
auth=(self.public_key, self.secret_key),
json_payload={"query": GRAPHQL_QUERY, "variables": variables},
)

errors = json_response.get('errors')
if errors:
raise Exception(errors[0].get('message'))
data = json_response.get("data", {})
code_generation = data.get("generateCode", {})
code = code_generation.get("code")
if not code:
raise Exception("Cant get the code. Received json from Quiver Cloud")
code = str(code)
return code

def document_from_string(self, schema, request_string):
source = self.generate_source(schema, request_string)
filename = "<document>"
code = compile(source, filename, "exec")

def uptodate():
return True

document = GraphQLCompiledDocument.from_code(
schema, code, uptodate, self.extra_namespace
)
return document
Empty file.
9 changes: 9 additions & 0 deletions graphql/backend/tests/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from graphql.type import (GraphQLField, GraphQLObjectType,
GraphQLSchema, GraphQLString)


Query = GraphQLObjectType('Query', lambda: {
'hello': GraphQLField(GraphQLString, resolver=lambda *_: "World"),
})

schema = GraphQLSchema(Query)
17 changes: 17 additions & 0 deletions graphql/backend/tests/test_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Tests for `graphql.backend.cache` module."""

import pytest

from ..core import GraphQLCoreBackend
from ..cache import GraphQLCachedBackend
from graphql.execution.executors.sync import SyncExecutor
from .schema import schema


def test_backend_is_cached_when_needed():
cached_backend = GraphQLCachedBackend(GraphQLCoreBackend())
document1 = cached_backend.document_from_string(schema, "{ hello }")
document2 = cached_backend.document_from_string(schema, "{ hello }")
assert document1 == document2
Loading