forked from graphql-python/gql
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Include support for GQL custom scalar types
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
Showing
4 changed files
with
215 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |