Skip to content

Commit 8a4b2a1

Browse files
authored
Add request handling module (#206)
* Crete base DCResponse DCResponse just wraps around the api response * NodeResponse * ObservationResponse * ResolveResponse * SparqlResponse * fix return * consistent naming * Update response.py * tests for response utilities * Move response scripts and tests * Improve endpoint docstrings * Implement custom errors * Add tests for custom errors Move file to match history * isort isort imports with google profile * move tests to right folder * correct module structure (imports) * isort tests * Fix name typo * Move response scripts and tests * Model node response Adds: - A Node dataclass which models an individual node (with dcid, name, etc) - A NodeGroup dataclass which represents a list of Node objects - An Arcs dataclass, which represents arcs (with a label and containing NodeGroups) - A Properties dataclass which represents a list of Properties * Model observation response Adds: - An Observation dataclass which models an individual observation (date, value) - An OrderedFacets dataclass which models the 'ordered facets' of observations - A Variable dataclass which represents a variable data (grouped by entity) - A Facet class which represents the metadata for a facet * Model resolve response Adds: - A candidate dataclass to model candidates in the response (with dcid and dominanType) - An Entity dataclass to model entities with their resolution candidates * Model sparql response Adds: - A Cell dataclass to model a single cell in a row - A Row modelling a row with cells. * resolve conflicts (rebase) Adds tests for node, observation, resolve and sparql models. * Add tests for custom errors * isort isort imports with google profile * Create request_handling.py Validates instance. Builds header. Manages post requests, including multi-page responses * Create test_request_handling.py * Update docstring Fixes a typo on the return type of the post_request function
1 parent c52fada commit 8a4b2a1

File tree

4 files changed

+596
-14
lines changed

4 files changed

+596
-14
lines changed

datacommons_client/tests/utils/test_error_handling.py renamed to datacommons_client/tests/endpoints/test_error_handling.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from requests import Request
22
from requests import Response
33

4-
from datacommons_client.utils.error_handling import APIError
5-
from datacommons_client.utils.error_handling import DataCommonsError
6-
from datacommons_client.utils.error_handling import DCAuthenticationError
7-
from datacommons_client.utils.error_handling import DCConnectionError
8-
from datacommons_client.utils.error_handling import DCStatusError
9-
from datacommons_client.utils.error_handling import InvalidDCInstanceError
4+
from datacommons_client.utils.error_hanlding import APIError
5+
from datacommons_client.utils.error_hanlding import DataCommonsError
6+
from datacommons_client.utils.error_hanlding import DCAuthenticationError
7+
from datacommons_client.utils.error_hanlding import DCConnectionError
8+
from datacommons_client.utils.error_hanlding import DCStatusError
9+
from datacommons_client.utils.error_hanlding import InvalidDCInstanceError
1010

1111

1212
def test_data_commons_error_default_message():
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
from unittest.mock import MagicMock
2+
from unittest.mock import patch
3+
4+
import pytest
5+
import requests
6+
7+
from datacommons_client.utils.error_hanlding import (
8+
DCAuthenticationError,
9+
DCConnectionError,
10+
DCStatusError,
11+
APIError,
12+
)
13+
from datacommons_client.utils.error_hanlding import InvalidDCInstanceError
14+
from datacommons_client.utils.request_handling import _check_instance_is_valid
15+
from datacommons_client.utils.request_handling import _fetch_with_pagination
16+
from datacommons_client.utils.request_handling import _merge_values
17+
from datacommons_client.utils.request_handling import _recursively_merge_dicts
18+
from datacommons_client.utils.request_handling import _send_post_request
19+
from datacommons_client.utils.request_handling import build_headers
20+
from datacommons_client.utils.request_handling import post_request
21+
from datacommons_client.utils.request_handling import resolve_instance_url
22+
23+
24+
def test_resolve_instance_url_default():
25+
"""Tests resolving the default Data Commons instance."""
26+
assert (
27+
resolve_instance_url("datacommons.org")
28+
== "https://api.datacommons.org/v2"
29+
)
30+
31+
32+
@patch("requests.get")
33+
def test_check_instance_is_valid_request_exception(mock_get):
34+
"""Tests that a RequestException raises InvalidDCInstanceError."""
35+
mock_get.side_effect = requests.exceptions.RequestException(
36+
"Request failed"
37+
)
38+
with pytest.raises(InvalidDCInstanceError):
39+
_check_instance_is_valid("https://invalid-instance")
40+
41+
42+
@patch("requests.post")
43+
def test_send_post_request_connection_error(mock_post):
44+
"""Tests that a ConnectionError raises DCConnectionError."""
45+
mock_post.side_effect = requests.exceptions.ConnectionError(
46+
"Connection failed"
47+
)
48+
with pytest.raises(DCConnectionError):
49+
_send_post_request("https://api.test.com", {}, {})
50+
51+
52+
@patch("datacommons_client.utils.request_handling._check_instance_is_valid")
53+
def test_resolve_instance_url_custom(mock_check_instance_is_valid):
54+
"""Tests resolving a custom Data Commons instance."""
55+
mock_check_instance_is_valid.return_value = (
56+
"https://custom-instance/core/api/v2"
57+
)
58+
59+
assert (
60+
resolve_instance_url("custom-instance")
61+
== "https://custom-instance/core/api/v2"
62+
)
63+
mock_check_instance_is_valid.assert_called_once_with(
64+
"https://custom-instance/core/api/v2"
65+
)
66+
67+
68+
@patch("requests.get")
69+
def test_check_instance_is_valid_valid(mock_get):
70+
"""Tests that a valid instance URL is correctly validated."""
71+
72+
# Create a mock response object with the expected JSON data and status code
73+
mock_response = MagicMock()
74+
mock_response.json.return_value = {"data": {"country/GTM": {}}}
75+
mock_response.status_code = 200
76+
mock_get.return_value = mock_response
77+
78+
# Mock the instance URL to test
79+
instance_url = "https://valid-instance"
80+
81+
# Assert that the instance URL is returned if it is valid
82+
assert _check_instance_is_valid(instance_url) == instance_url
83+
mock_get.assert_called_once_with(
84+
f"{instance_url}/node?nodes=country%2FGTM&property=->name"
85+
)
86+
87+
88+
@patch("requests.get")
89+
def test_check_instance_is_valid_invalid(mock_get):
90+
"""Tests that an invalid instance URL raises the appropriate exception."""
91+
mock_response = MagicMock()
92+
mock_response.json.return_value = {"error": "Not Found"}
93+
mock_response.status_code = 404
94+
mock_get.return_value = mock_response
95+
96+
with pytest.raises(InvalidDCInstanceError):
97+
_check_instance_is_valid("https://invalid-instance")
98+
99+
100+
@patch("requests.post")
101+
def test_send_post_request_500_status_error(mock_post):
102+
"""Tests that a 500-level HTTP error raises DCStatusError."""
103+
mock_response = MagicMock()
104+
mock_response.status_code = 500
105+
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
106+
response=mock_response
107+
)
108+
mock_post.return_value = mock_response
109+
110+
with pytest.raises(DCStatusError):
111+
_send_post_request("https://api.test.com", {}, {})
112+
113+
114+
@patch("requests.post")
115+
def test_send_post_request_other_http_error(mock_post):
116+
"""Tests that non-500 HTTP errors raise APIError."""
117+
mock_response = MagicMock()
118+
mock_response.status_code = 404
119+
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
120+
response=mock_response
121+
)
122+
mock_post.return_value = mock_response
123+
124+
with pytest.raises(APIError):
125+
_send_post_request("https://api.test.com", {}, {})
126+
127+
128+
def test_build_headers_with_api_key():
129+
"""Tests building headers with an API key."""
130+
headers = build_headers("test-api-key")
131+
assert headers["Content-Type"] == "application/json"
132+
assert headers["X-API-Key"] == "test-api-key"
133+
134+
135+
def test_build_headers_without_api_key():
136+
"""Tests building headers without an API key."""
137+
headers = build_headers()
138+
assert headers["Content-Type"] == "application/json"
139+
assert "X-API-Key" not in headers
140+
141+
142+
@patch("requests.post")
143+
def test_send_post_request_success(mock_post):
144+
"""Tests a successful POST request."""
145+
146+
# Create a mock response object with the expected JSON data and status code
147+
mock_response = MagicMock()
148+
mock_response.status_code = 200
149+
mock_response.json.return_value = {"success": True}
150+
mock_post.return_value = mock_response
151+
152+
# Mock the POST request
153+
url = "https://api.test.com"
154+
payload = {"key": "value"}
155+
headers = {"Content-Type": "application/json"}
156+
157+
response = _send_post_request(url, payload, headers)
158+
assert response.status_code == 200
159+
assert response.json() == {"success": True}
160+
161+
162+
@patch("requests.post")
163+
def test_send_post_request_http_error(mock_post):
164+
"""Tests handling an HTTP error during a POST request."""
165+
# Create a mock response object with a 401 status code
166+
mock_response = MagicMock()
167+
mock_response.status_code = 401
168+
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
169+
response=mock_response
170+
)
171+
mock_post.return_value = mock_response
172+
173+
# Mock the POST request and assert that a DCAuthenticationError is raised
174+
with pytest.raises(DCAuthenticationError):
175+
_send_post_request("https://api.test.com", {}, {})
176+
177+
178+
def test_recursively_merge_dicts():
179+
"""Tests recursive merging of dictionaries."""
180+
# Test merging two dictionaries with nested dictionaries and lists
181+
base = {"a": {"b": 1}, "c": [1, 2]}
182+
new = {"a": {"d": 2}, "c": [3], "e": 5}
183+
result = _recursively_merge_dicts(base, new)
184+
185+
# Assert that the dictionaries are merged correctly
186+
assert result == {"a": {"b": 1, "d": 2}, "c": [1, 2, 3], "e": 5}
187+
188+
189+
def test_merge_values_dicts():
190+
"""Tests merging of dictionary values."""
191+
base = {"a": 1}
192+
new = {"b": 2}
193+
result = _merge_values(base, new)
194+
assert result == {"a": 1, "b": 2}
195+
196+
197+
def test_merge_values_lists():
198+
"""Tests merging of list values."""
199+
base = [1, 2]
200+
new = [3, 4]
201+
result = _merge_values(base, new)
202+
assert result == [1, 2, 3, 4]
203+
204+
205+
def test_merge_values_other():
206+
"""Tests merging non-dict, non-list values."""
207+
base = "value1"
208+
new = "value2"
209+
result = _merge_values(base, new)
210+
assert result == ["value1", "value2"]
211+
212+
213+
def test_merge_values_complex_conflict():
214+
"""Tests merging deeply nested, repeated objects."""
215+
216+
# Nested but simple base
217+
base = {
218+
"key1": {
219+
"nested1": {
220+
"subkey1": "value1",
221+
"subkey2": [1, 2],
222+
},
223+
"nested2": "value2",
224+
},
225+
"key2": [1, 2, 3],
226+
"key3": "conflict1",
227+
}
228+
229+
# Nested but complex new
230+
new = {
231+
"key1": {
232+
"nested1": {
233+
"subkey1": "new_value1", # Already in base
234+
"subkey3": "new_value2", # New key
235+
},
236+
"nested2": ["new_value3"], # Conflicts with base (type change)
237+
},
238+
"key2": [4, 5], # Should merge lists
239+
"key3": "conflict2", # Should create a list due to conflict
240+
"key4": {"new_nested": "new_value4"}, # New key
241+
}
242+
243+
# Expected result which keeps all data
244+
expected_result = {
245+
"key1": {
246+
"nested1": {
247+
"subkey1": ["value1", "new_value1"],
248+
"subkey2": [1, 2],
249+
"subkey3": "new_value2",
250+
},
251+
"nested2": ["value2", ["new_value3"]],
252+
},
253+
"key2": [1, 2, 3, 4, 5],
254+
"key3": ["conflict1", "conflict2"],
255+
"key4": {"new_nested": "new_value4"},
256+
}
257+
258+
result = _merge_values(base, new)
259+
assert result == expected_result
260+
261+
262+
@patch("datacommons_client.utils.request_handling._send_post_request")
263+
def test_fetch_with_pagination(mock_send_post_request):
264+
"""Tests fetching and merging paginated API responses."""
265+
266+
# Mock the response JSON data for two pages
267+
mock_response = MagicMock()
268+
mock_response.json.side_effect = [
269+
{"data": {"page1": True}, "nextToken": "token1"},
270+
{"data": {"page2": True}},
271+
]
272+
mock_send_post_request.side_effect = [mock_response, mock_response]
273+
274+
# Mock the POST request and assert that the results are merged correctly
275+
url = "https://api.test.com"
276+
payload = {}
277+
headers = {}
278+
279+
result = _fetch_with_pagination(url, payload, headers)
280+
assert result == {"data": {"page1": True, "page2": True}}
281+
282+
283+
@patch("datacommons_client.utils.request_handling._send_post_request")
284+
def test_fetch_with_pagination_invalid_json(mock_send_post_request):
285+
"""Tests that invalid JSON response raises APIError."""
286+
mock_response = MagicMock()
287+
mock_response.json.side_effect = ValueError("Invalid JSON")
288+
mock_send_post_request.return_value = mock_response
289+
290+
with pytest.raises(APIError):
291+
_fetch_with_pagination("https://api.test.com", {}, {})
292+
293+
294+
@patch("datacommons_client.utils.request_handling._fetch_with_pagination")
295+
def test_post_request(mock_fetch_with_pagination):
296+
"""Tests the `post_request` function with mock pagination."""
297+
mock_fetch_with_pagination.return_value = {"result": "data"}
298+
result = post_request("https://api.test.com", {}, {})
299+
assert result == {"result": "data"}
300+
301+
302+
def test_post_request_invalid_payload():
303+
"""Tests that a non-dictionary payload raises a ValueError."""
304+
with pytest.raises(ValueError):
305+
post_request("https://api.test.com", ["not", "a", "dict"], {})

datacommons_client/tests/endpoints/test_response.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
from datacommons_client.endpoints.response import _unpack_arcs
2-
from datacommons_client.endpoints.response import DCResponse
3-
from datacommons_client.endpoints.response import extract_observations
4-
from datacommons_client.endpoints.response import flatten_properties
5-
from datacommons_client.endpoints.response import NodeResponse
6-
from datacommons_client.endpoints.response import ObservationResponse
7-
from datacommons_client.endpoints.response import ResolveResponse
8-
from datacommons_client.endpoints.response import SparqlResponse
91
from datacommons_client.models.observation import Facet
102
from datacommons_client.models.observation import Observation
113
from datacommons_client.models.observation import OrderedFacets
124
from datacommons_client.models.observation import Variable
5+
from datacommons_client.utils.response import _unpack_arcs
6+
from datacommons_client.utils.response import DCResponse
7+
from datacommons_client.utils.response import extract_observations
8+
from datacommons_client.utils.response import flatten_properties
9+
from datacommons_client.utils.response import NodeResponse
10+
from datacommons_client.utils.response import ObservationResponse
11+
from datacommons_client.utils.response import ResolveResponse
12+
from datacommons_client.utils.response import SparqlResponse
1313

1414
### ----- Test DCResponse ----- ###
1515

0 commit comments

Comments
 (0)