Skip to content

Commit

Permalink
Include support for GQL custom scalar types
Browse files Browse the repository at this point in the history
Move the GQLResponseParser into the gql library.

Each time a GQL query is made by the client, the response is passed through
ResponseParser.parse() where it is processed.

Configure your own custom scalars as follows:
```
    custom_scalars = {
        'SomeCustomScalar': <ScalarClass>
    }

    gql_client = GQLClient(transport=gql_transport,
                           schema=schema,
                           custom_scalars=custom_scalars)

    gql_client.execute(...)
```
<ScalarClass> should have a .parse_value(value) function

There are a few anti-patterns I have had to include in order to support
some new functionality that we require:
- client.execute now accepts `url` and `headers` as arguments, since we
  need to be able to set these on a per request basis. Previously they were
  supplied by the transport (which gets set only once at initialization
  time).
- As a consequence, the url supplied in to the transport goes unused if a
  url is passed in to `execute()`. It is a required field so I have to pass
  in a string, but it's not the best.
  • Loading branch information
Awais Hussain committed May 1, 2018
1 parent 21d581e commit 1bbfbd7
Show file tree
Hide file tree
Showing 4 changed files with 215 additions and 2 deletions.
7 changes: 6 additions & 1 deletion gql/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from graphql.validation import validate

from .transport.local_schema import LocalSchemaTransport
from .response_parser import ResponseParser

log = logging.getLogger(__name__)

Expand All @@ -17,7 +18,7 @@ def __init__(self, retries_count, last_exception):

class Client(object):
def __init__(self, schema=None, introspection=None, type_def=None, transport=None,
fetch_schema_from_transport=False, retries=0):
fetch_schema_from_transport=False, custom_scalars={}, retries=0):
assert not(type_def and introspection), 'Cant provide introspection type definition at the same time'
if transport and fetch_schema_from_transport:
assert not schema, 'Cant fetch the schema from transport if is already provided'
Expand All @@ -36,6 +37,7 @@ def __init__(self, schema=None, introspection=None, type_def=None, transport=Non
self.introspection = introspection
self.transport = transport
self.retries = retries
self.response_parser = ResponseParser(schema, custom_scalars) if custom_scalars else None

def validate(self, document):
if not self.schema:
Expand All @@ -52,6 +54,9 @@ def execute(self, document, *args, **kwargs):
if result.errors:
raise Exception(str(result.errors[0]))

if self.response_parser:
result.data = self.response_parser.parse(result.data)

return result.data

def _get_result(self, document, *args, **kwargs):
Expand Down
114 changes: 114 additions & 0 deletions gql/response_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
from typing import Any, Dict, Callable, Optional, List

from graphql.type.schema import GraphQLSchema
from graphql.type.definition import GraphQLObjectType, GraphQLField, GraphQLScalarType


class ResponseParser(object):
"""The challenge is to substitute custom scalars in a GQL response with their
decoded counterparts.
To solve this problem, we first need to iterate over all the fields in the
response (which is done in the `_traverse()` function).
Each time we find a field which has type scalar and is a custom scalar, we
need to replace the value of that field with the decoded value. All of this
logic happens in `_substitute()`.
Public Interface:
parse(): call parse with a GQL response to replace all instances of custom
scalar strings with their deserialized representation."""

def __init__(self, schema: GraphQLSchema, custom_scalars: Dict[str, Any] = {}) -> None:
""" schema: a graphQL schema in the GraphQLSchema format
custom_scalars: a Dict[str, Any],
where str is the name of the custom scalar type, and
Any is a class which has a `parse_value()` function"""
self.schema = schema
self.custom_scalars = custom_scalars

def _follow_type_chain(self, node: Any) -> Any:
"""In the schema GraphQL types are often listed with the format
`obj.type.of_type...` where there are 0 or more 'of_type' fields before
you get to the type you are interested in.
This is a convenience method to help us get to these nested types."""
if isinstance(node, GraphQLObjectType):
return node

field_type = node.type
while hasattr(field_type, 'of_type'):
field_type = field_type.of_type

return field_type

def _get_scalar_type_name(self, field: GraphQLField) -> Optional[str]:
"""Returns the name of the type if the type is a scalar type.
Returns None otherwise"""
node = self._follow_type_chain(field)
if isinstance(node, GraphQLScalarType):
return node.name
return None

def _lookup_scalar_type(self, keys: List[str]) -> Optional[str]:
"""
`keys` is a breadcrumb trail telling us where to look in the GraphQL schema.
By default the root level is `schema.query`, if that fails, then we check
`schema.mutation`.
If keys (e.g. ['wallet', 'balance']) points to a scalar type, then
this function returns the name of that type. (e.g. 'Money')
If it is not a scalar type (e..g a GraphQLObject or list), then this
function returns None"""

def iterate(node: Any, lookup: List[str]):
lookup = lookup.copy()
if not lookup:
return self._get_scalar_type_name(node)

final_node = self._follow_type_chain(node)
return iterate(final_node.fields[lookup.pop(0)], lookup)

try:
return iterate(self.schema.get_query_type(), keys)
except (KeyError, AttributeError):
try:
return iterate(self.schema.get_mutation_type(), keys)
except (KeyError, AttributeError):
return None

def _substitute(self, keys: List[str], value: Any) -> Any:
"""Looks in the GraphQL schema to find the type identified by 'keys'
If that type is not a custom scalar, we return the original value.
If it is a custom scalar, we return the deserialized value, as
processed by `<CustomScalarType>.parse_value()`"""
scalar_type = self._lookup_scalar_type(keys)
if scalar_type and scalar_type in self.custom_scalars:
return self.custom_scalars[scalar_type].parse_value(value)
return value

def _traverse(self, response: Dict[str, Any], substitute: Callable) -> Dict[str, Any]:
"""Recursively traverses the GQL response and calls the `substitute`
function on all leaf nodes. The function is called with 2 arguments:
keys: List[str] is a breadcrumb trail telling us where we are in the
response, and therefore, where to look in the GQL Schema.
value: Any is the value at that node in the tree
Builds a new tree with the substituted values so `response` is not
modified."""
def iterate(node: Any, keys: List[str] = []):
if isinstance(node, dict):
result = {}
for _key, value in node.items():
result[_key] = iterate(value, keys + [_key])
return result
elif isinstance(node, list):
return [(iterate(item, keys)) for item in node]
else:
return substitute(keys, node)
return iterate(response)

def parse(self, response: Dict[str, Any]) -> Dict[str, Any]:
return self._traverse(response, self._substitute)
2 changes: 1 addition & 1 deletion gql/transport/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def execute(self, document, variable_values=None, timeout=None):
'timeout': timeout or self.default_timeout,
data_key: payload
}
request = requests.post(self.url, **post_args)
request = requests.post(url or self.url, **post_args)
request.raise_for_status()

result = request.json()
Expand Down
94 changes: 94 additions & 0 deletions tests/test_response_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Tests for the GraphQL Response Parser.
These tests are worthless until I have a schema I can work with.
"""
import copy
from gql.response_parser import ResponseParser


class Capitalize():
def parse_value(self, value: str):
return value.upper();

def test_scalar_type_name_for_scalar_field_returns_name(gql_schema):
parser = ResponseParser(gql_schema)
schema_obj = gql_schema.get_type_map().get('Wallet')

assert parser._get_scalar_type_name(schema_obj.fields['balance']) == 'Money'


def test_scalar_type_name_for_non_scalar_field_returns_none(gql_schema):
parser = ResponseParser(gql_schema)
schema_obj = gql_schema.get_type_map().get('Wallet')

assert parser._get_scalar_type_name(schema_obj.fields['user']) is None

def test_lookup_scalar_type(gql_schema):
parser = ResponseParser(gql_schema)

assert parser._lookup_scalar_type(["wallet"]) is None
assert parser._lookup_scalar_type(["searchWallets"]) is None
assert parser._lookup_scalar_type(["wallet", "balance"]) == 'Money'
assert parser._lookup_scalar_type(["searchWallets", "balance"]) == 'Money'
assert parser._lookup_scalar_type(["wallet", "name"]) == 'String'
assert parser._lookup_scalar_type(["wallet", "invalid"]) is None

def test_lookup_scalar_type_in_mutation(gql_schema):
parser = ResponseParser(gql_schema)

assert parser._lookup_scalar_type(["manualWithdraw", "agentTransaction"]) is None
assert parser._lookup_scalar_type(["manualWithdraw", "agentTransaction", "amount"]) == 'Money'

def test_parse_response(gql_schema):
custom_scalars = {
'Money': Capitalize
}
parser = ResponseParser(gql_schema, custom_scalars)

response = {
'wallet': {
'id': 'some_id',
'name': 'U1_test',
}
}

expected = {
'wallet': {
'id': 'some_id',
'name': 'U1_test',
}
}

assert parser.parse(response) == expected
assert response['wallet']['balance'] == 'CFA 3850'

def test_parse_response_containing_list(gql_schema):
custom_scalars = {
'Money': M
}
parser = ResponseParser(gql_schema, custom_scalars)

response = {
"searchWallets": [
{
"id": "W_wz518BXTDJuQ",
"name": "U2_test",
"balance": "CFA 4148"
},
{
"id": "W_uOe9fHPoKO21",
"name": "Agent_test",
"balance": "CFA 2641"
}
]
}

expected = copy.deepcopy(response)
expected['searchWallets'][0]['balance'] = M("CFA", "4148")
expected['searchWallets'][1]['balance'] = M("CFA", "2641")

result = parser.parse(response)

assert result == expected
assert response['searchWallets'][0]['balance'] == "CFA 4148"
assert response['searchWallets'][1]['balance'] == "CFA 2641"

0 comments on commit 1bbfbd7

Please sign in to comment.