8000 Simple Paging Endpoint for GraphQL/Metadata API (#623) · scuml/server-client-python@1630205 · GitHub
[go: up one dir, main page]

Skip to content
8000

Commit 1630205

Browse files
authored
Simple Paging Endpoint for GraphQL/Metadata API (tableau#623)
Because GraphQL can be arbitrarily complex and nested, we can't get as smart with an automatic Pager object without parsing the query, and that's a can of worms. So for now, I added a new endpoint that will take a single query with one set of pagination parameters and run through it until it ends. It's not very smart, but it works.
1 parent 1392946 commit 1630205

File tree

7 files changed

+182
-7
lines changed

7 files changed

+182
-7
lines changed

tableauserverclient/server/endpoint/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ class NonXMLResponseError(Exception):
5050
pass
5151

5252

53+
class InvalidGraphQLQuery(Exception):
54+
pass
55+
56+
5357
class GraphQLError(Exception):
5458
def __init__(self, error_payload):
5559
self.error = error_payload
Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,63 @@
11
from .endpoint import Endpoint, api
2-
from .exceptions import GraphQLError
3-
2+
from .exceptions import GraphQLError, InvalidGraphQLQuery
43
import logging
54
import json
65

76
logger = logging.getLogger('tableau.endpoint.metadata')
87

98

9+
def is_valid_paged_query(parsed_query):
10+
"""Check that the required $first and $afterToken variables are present in the query.
11+
Also check that we are asking for the pageInfo object, so we get the endCursor. There
12+
is no way to do this relilably without writing a GraphQL parser, so simply check that
13+
that the string contains 'hasNextPage' and 'endCursor'"""
14+
return all(k in parsed_query['variables'] for k in ('first', 'afterToken')) and \
15+
'hasNextPage' in parsed_query['query'] and \
16+
'endCursor' in parsed_query['query']
17+
18+
19+
def extract_values(obj, key):
20+
"""Pull all values of specified key from nested JSON.
21+
Taken from: https://hackersandslackers.com/extract-data-from-complex-json-python/"""
22+
arr = []
23+
24+
def extract(obj, arr, key):
25+
"""Recursively search for values of key in JSON tree."""
26+
if isinstance(obj, dict):
27+
for k, v in obj.items():
28+
if isinstance(v, (dict, list)):
29+
extract(v, arr, key)
30+
elif k == key:
31+
arr.append(v)
32+
elif isinstance(obj, list):
33+
for item in obj:
34+
extract(item, arr, key)
35+
return arr
36+
37+
results = extract(obj, arr, key)
38+
return results
39+
40+
41+
def get_page_info(result):
42+
next_page = extract_values(result, 'hasNextPage').pop()
43+
cursor = extract_values(result, 'endCursor').pop()
44+
return next_page, cursor
45+
46+
1047
class Metadata(Endpoint):
1148
@property
1249
def baseurl(self):
1350
return "{0}/api/metadata/graphql".format(self.parent_srv.server_address)
1451

15-
@api("3.2")
52+
@api("3.5")
1653
def query(self, query, variables=None, abort_on_error=False):
1754
logger.info('Querying Metadata API')
1855
url = self.baseurl
1956

2057
try:
2158
graphql_query = json.dumps({'query': query, 'variables': variables})
22-
except Exception:
23-
# Place holder for now
24-
raise Exception('Must provide a string')
59+
except Exception as e:
60+
raise InvalidGraphQLQuery('Must provide a string')
2561

2662
# Setting content type because post_reuqest defaults to text/xml
2763
server_response = self.post_request(url, graphql_query, content_type='text/json')
@@ -31,3 +67,55 @@ def query(self, query, variables=None, abort_on_error=False):
3167
raise GraphQLError(results['errors'])
3268

3369
return results
70+
71+
@api("3.5")
72+
def paginated_query(self, query, variables=None, abort_on_error=False):
73+
logger.info('Querying Metadata API using a Paged Query')
74+
url = self.baseurl
75+
76+
if variables is None:
77+
# default paramaters
78+
variables = {'first': 100, 'afterToken': None}
79+
elif (('first' in variables) and ('afterToken' not in variables)):
80+
# they passed a page size but not a token, probably because they're starting at `null` token
81+
variables.update({'afterToken': None})
82+
83+
graphql_query = json.dumps({'query': query, 'variables': variables})
84+
parsed_query = json.loads(graphql_query)
85+
86+
if not is_valid_paged_query(parsed_query):
87+
raise InvalidGraphQLQuery('Paged queries must have a `$first` and `$afterToken` variables as well as '
88+
'a pageInfo object with `endCursor` and `hasNextPage`')
89+
90+
results_dict = {'pages': []}
91+
paginated_results = results_dict['pages']
92+
93+
# get first page
94+
server_response = self.post_request(url, graphql_query, content_type='text/json')
95+
results = server_response.json()
96+
97+
if abort_on_error and results.get('errors', None):
98+
raise GraphQLError(results['errors'])
99+
100+
paginated_results.append(results)
101+
102+
# repeat
103+
has_another_page, cursor = get_page_info(results)
104+
105+
while has_another_page:
106+
# Update the page
107+
variables.update({'afterToken': cursor})
108+
# make the call
109+
logger.debug("Calling Token: " + cursor)
110+
graphql_query = json.dumps({'query': query, 'variables': variables})
111+
server_response = self.post_request(url, graphql_query, content_type='text/json')
112+
results = server_response.json()
113+
# verify response
114+
if abort_on_error and results.get('errors', None):
115+
raise GraphQLError(results['errors'])
116+
# save results and repeat
117+
paginated_results.append(results)
118+
has_another_page, cursor = get_page_info(results)
119+
120+
logger.info('Sucessfully got all results for paged query')
121+
return results_dict

test/assets/metadata_paged_1.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"data": {
3+
"publishedDatasourcesConnection": {
4+
"pageInfo": {
5+
"hasNextPage": true,
6+
"endCursor": "eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwMzllNWQ1LTI1ZmEtMTk2Yi1jNjZlLWMwNjc1ODM5ZTBiMCJ9fQ=="
7+
},
8+
"nodes": [
9+
{
10+
"id": "0039e5d5-25fa-196b-c66e-c0675839e0b0"
11+
}
12+
]
13+
}
14+
}
15+
}

test/assets/metadata_paged_2.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"data": {
3+
"publishedDatasourcesConnection": {
4+
"pageInfo": {
5+
"hasNextPage": true,
6+
"endCursor": "eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwYjE5MWNlLTYwNTUtYWZmNS1lMjc1LWMyNjYxMGM4YzRkNiJ9fQ=="
7+
},
8+
"nodes": [
9+
{
10+
"id": "00b191ce-6055-aff5-e275-c26610c8c4d6"
11+
}
12+
]
13+
}
14+
}
15+
}

test/assets/metadata_paged_3.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"data": {
3+
"publishedDatasourcesConnection": {
4+
"pageInfo": {
5+
"hasNextPage": false,
6+
"endCursor": "eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAyZjNlNGQ4LTg1NmEtZGEzNi1mNmM1LWM5MDA5NDVjNTdiOSJ9fQ=="
7+
},
8+
"nodes": [
9+
{
10+
"id": "02f3e4d8-856a-da36-f6c5-c900945c57b9"
11+
}
12+
]
13+
}
14+
}
15+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{'pages': [{'data': {'publishedDatasourcesConnection': {'nodes': [{'id': '0039e5d5-25fa-196b-c66e-c0675839e0b0'}],
2+
'pageInfo': {'endCursor': 'eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwMzllNWQ1LTI1ZmEtMTk2Yi1jNjZlLWMwNjc1ODM5ZTBiMCJ9fQ==',
3+
'hasNextPage': True}}}},
4+
{'data': {'publishedDatasourcesConnection': {'nodes': [{'id': '00b191ce-6055-aff5-e275-c26610c8c4d6'}],
5+
'pageInfo': {'endCursor': 'eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwYjE5MWNlLTYwNTUtYWZmNS1lMjc1LWMyNjYxMGM4YzRkNiJ9fQ==',
6+
'hasNextPage': True}}}},
7+
{'data': {'publishedDatasourcesConnection': {'nodes': [{'id': '02f3e4d8-856a-da36-f6c5-c900945c57b9'}],
8+
'pageInfo': {'endCursor': 'eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAyZjNlNGQ4LTg1NmEtZGEzNi1mNmM1LWM5MDA5NDVjNTdiOSJ9fQ==',
9+
'hasNextPage': False}}}}]}

test/test_metadata.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010

1111
METADATA_QUERY_SUCCESS = os.path.join(TEST_ASSET_DIR, 'metadata_query_success.json')
1212
METADATA_QUERY_ERROR = os.path.join(TEST_ASSET_DIR, 'metadata_query_error.json')
13+
EXPECTED_PAGED_DICT = os.path.join(TEST_ASSET_DIR, 'metadata_query_expected_dict.dict')
14+
15+
METADATA_PAGE_1 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_1.json')
16+
METADATA_PAGE_2 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_2.json')
17+
METADATA_PAGE_3 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_3.json')
1318

1419
EXPECTED_DICT = {'publishedDatasources':
1520
[{'id': '01cf92b2-2d17-b656-fc48-5c25ef6d5352', 'name': 'Batters (TestV1)'},
@@ -30,7 +35,7 @@ class MetadataTests(unittest.TestCase):
3035
def setUp(self):
3136
self.server = TSC.Server('http://test')
3237
self.baseurl = self.server.metadata.baseurl
33-
self.server.version = "3.2"
38+
self.server.version = "3.5"
3439

3540
self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67'
3641
self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM'
@@ -46,6 +51,30 @@ def test_metadata_query(self):
4651

4752
self.assertDictEqual(EXPECTED_DICT, datasources)
4853

54+
def test_paged_metadata_query(self):
55+
with open(EXPECTED_PAGED_DICT, 'rb') as f:
56+
expected = eval(f.read())
57+
58+
# prepare the 3 pages of results
59+
with open(METADATA_PAGE_1, 'rb') as f:
60+
result_1 = f.read().decode()
61+
with open(METADATA_PAGE_2, 'rb') as f:
62+
result_2 = f.read().decode()
63+
with open(METADATA_PAGE_3, 'rb') as f:
64+
result_3 = f.read().decode()
65+
66+
with requests_mock.mock() as m:
67+
m.post(self.baseurl, [{'text': result_1, 'status_code': 200},
68+
{'text': result_2, 'status_code': 200},
69+
{'text': result_3, 'status_code': 200}])
70+
71+
# validation checks for endCursor and hasNextPage,
72+
# but the query text doesn't matter for the test
73+
actual = self.server.metadata.paginated_query('fake query endCursor hasNextPage',
74+
variables={'first': 1, 'afterToken': None})
75+
76+
self.assertDictEqual(expected, actual)
77+
4978
def test_metadata_query_ignore_error(self):
5079
with open(METADATA_QUERY_ERROR, 'rb') as f:
5180
response_json = json.loads(f.read().decode())

0 commit comments

Comments
 (0)
0