Skip to content

Commit 57ea77c

Browse files
authored
Implement API and EndPoint classes to interact with Data Commons API (#209)
This PR introduces a new API class and Endpoint class. The API class handles environment setup and makes POST requests (i.e it is used to interface with the Data Commons API), while the Endpoint class represents specific endpoints (not yet implemented beyond this generic case) within the Data Commons API and uses the API instance to make requests.
1 parent 0c1f1a5 commit 57ea77c

File tree

4 files changed

+283
-12
lines changed

4 files changed

+283
-12
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from typing import Any, Dict, Optional
2+
3+
from datacommons_client.utils.request_handling import build_headers
4+
from datacommons_client.utils.request_handling import check_instance_is_valid
5+
from datacommons_client.utils.request_handling import post_request
6+
from datacommons_client.utils.request_handling import resolve_instance_url
7+
8+
9+
class API:
10+
"""Represents a configured API interface to the Data Commons API.
11+
12+
This class handles environment setup, resolving the base URL, building headers,
13+
or optionally using a fully qualified URL directly. It can be used standalone
14+
to interact with the API or in combination with Endpoint classes.
15+
"""
16+
17+
def __init__(
18+
self,
19+
api_key: Optional[str] = None,
20+
dc_instance: Optional[str] = None,
21+
url: Optional[str] = None,
22+
):
23+
"""
24+
Initializes the API instance.
25+
26+
Args:
27+
api_key: The API key for authentication. Defaults to None.
28+
dc_instance: The Data Commons instance domain. Ignored if `url` is provided.
29+
Defaults to 'datacommons.org' if both `url` and `dc_instance` are None.
30+
url: A fully qualified URL for the base API. This may be useful if more granular control
31+
of the API is required (for local development, for example). If provided, dc_instance`
32+
should not be provided.
33+
34+
Raises:
35+
ValueError: If both `dc_instance` and `url` are provided.
36+
"""
37+
if dc_instance and url:
38+
raise ValueError("Cannot provide both `dc_instance` and `url`.")
39+
40+
if not dc_instance and not url:
41+
dc_instance = "datacommons.org"
42+
43+
self.headers = build_headers(api_key)
44+
45+
if url is not None:
46+
# Use the given URL directly (strip trailing slash)
47+
self.base_url = check_instance_is_valid(url.rstrip("/"))
48+
else:
49+
# Resolve from dc_instance
50+
self.base_url = resolve_instance_url(dc_instance)
51+
52+
def __repr__(self) -> str:
53+
"""Returns a readable representation of the API object.
54+
55+
Indicates the base URL and if it's authenticated.
56+
57+
Returns:
58+
str: A string representation of the API object.
59+
"""
60+
has_auth = " (Authenticated)" if "X-API-Key" in self.headers else ""
61+
return f"<API at {self.base_url}{has_auth}>"
62+
63+
def post(
64+
self, payload: dict[str, Any], endpoint: Optional[str] = None
65+
) -> Dict[str, Any]:
66+
"""Makes a POST request using the configured API environment.
67+
68+
If `endpoint` is provided, it will be appended to the base_url. Otherwise,
69+
it will just POST to the base URL.
70+
71+
Args:
72+
payload: The JSON payload for the POST request.
73+
endpoint: An optional endpoint path to append to the base URL.
74+
75+
Returns:
76+
A dictionary containing the merged response data.
77+
78+
Raises:
79+
ValueError: If the payload is not a valid dictionary.
80+
"""
81+
if not isinstance(payload, dict):
82+
raise ValueError("Payload must be a dictionary.")
83+
84+
url = (
85+
self.base_url if endpoint is None else f"{self.base_url}/{endpoint}"
86+
)
87+
return post_request(url=url, payload=payload, headers=self.headers)
88+
89+
90+
class Endpoint:
91+
"""Represents a specific endpoint within the Data Commons API.
92+
93+
This class leverages an API instance to make requests. It does not
94+
handle instance resolution or headers directly; that is delegated to the API instance.
95+
96+
Attributes:
97+
endpoint (str): The endpoint path (e.g., 'node').
98+
api (API): The API instance providing configuration and the `post` method.
99+
"""
100+
101+
def __init__(self, endpoint: str, api: API):
102+
"""
103+
Initializes the Endpoint instance.
104+
105+
Args:
106+
endpoint: The endpoint path (e.g., 'node').
107+
api: An API instance that provides the environment configuration.
108+
"""
109+
self.endpoint = endpoint
110+
self.api = api
111+
112+
def __repr__(self) -> str:
113+
"""Returns a readable representation of the Endpoint object.
114+
115+
Shows the endpoint and underlying API configuration.
116+
117+
Returns:
118+
str: A string representation of the Endpoint object.
119+
"""
120+
return f"<{self.endpoint.title()} Endpoint using {repr(self.api)}>"
121+
122+
def post(self, payload: dict[str, Any]) -> Dict[str, Any]:
123+
"""Makes a POST request to the specified endpoint using the API instance.
124+
125+
Args:
126+
payload: The JSON payload for the POST request.
127+
128+
Returns:
129+
A dictionary with the merged API response data.
130+
131+
Raises:
132+
ValueError: If the payload is not a valid dictionary.
133+
"""
134+
return self.api.post(payload=payload, endpoint=self.endpoint)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
from unittest.mock import patch
2+
3+
import pytest
4+
5+
from datacommons_client.endpoints.base import API
6+
from datacommons_client.endpoints.base import Endpoint
7+
8+
9+
@patch("datacommons_client.endpoints.base.build_headers")
10+
@patch("datacommons_client.endpoints.base.resolve_instance_url")
11+
def test_api_initialization_default(
12+
mock_resolve_instance_url, mock_build_headers
13+
):
14+
"""Tests default API initialization with `datacommons.org` instance."""
15+
mock_resolve_instance_url.return_value = "https://api.datacommons.org/v2"
16+
mock_build_headers.return_value = {"Content-Type": "application/json"}
17+
18+
api = API()
19+
20+
assert api.base_url == "https://api.datacommons.org/v2"
21+
assert api.headers == {"Content-Type": "application/json"}
22+
mock_resolve_instance_url.assert_called_once_with("datacommons.org")
23+
mock_build_headers.assert_called_once_with(None)
24+
25+
26+
@patch("datacommons_client.endpoints.base.build_headers")
27+
def test_api_initialization_with_url(mock_build_headers):
28+
"""Tests API initialization with a fully qualified URL."""
29+
mock_build_headers.return_value = {"Content-Type": "application/json"}
30+
31+
api = API(url="https://custom_instance.api/v2")
32+
assert api.base_url == "https://custom_instance.api/v2"
33+
assert api.headers == {"Content-Type": "application/json"}
34+
35+
36+
@patch("datacommons_client.endpoints.base.build_headers")
37+
@patch("datacommons_client.endpoints.base.resolve_instance_url")
38+
def test_api_initialization_with_dc_instance(
39+
mock_resolve_instance_url, mock_build_headers
40+
):
41+
"""Tests API initialization with a custom Data Commons instance."""
42+
mock_resolve_instance_url.return_value = "https://custom-instance/api/v2"
43+
mock_build_headers.return_value = {"Content-Type": "application/json"}
44+
45+
api = API(dc_instance="custom-instance")
46+
47+
assert api.base_url == "https://custom-instance/api/v2"
48+
assert api.headers == {"Content-Type": "application/json"}
49+
mock_resolve_instance_url.assert_called_once_with("custom-instance")
50+
51+
52+
def test_api_initialization_invalid_args():
53+
"""Tests API initialization with both `dc_instance` and `url` raises a ValueError."""
54+
with pytest.raises(ValueError):
55+
API(dc_instance="custom-instance", url="https://custom.api/v2")
56+
57+
58+
def test_api_repr():
59+
"""Tests the string representation of the API object."""
60+
api = API(url="https://custom_instance.api/v2", api_key="test-key")
61+
assert (
62+
repr(api) == "<API at https://custom_instance.api/v2 (Authenticated)>"
63+
)
64+
65+
api = API(url="https://custom_instance.api/v2")
66+
assert repr(api) == "<API at https://custom_instance.api/v2>"
67+
68+
69+
@patch("datacommons_client.endpoints.base.post_request")
70+
def test_api_post_request(mock_post_request):
71+
"""Tests making a POST request using the API object."""
72+
mock_post_request.return_value = {"success": True}
73+
74+
api = API(url="https://custom_instance.api/v2")
75+
payload = {"key": "value"}
76+
77+
response = api.post(payload=payload, endpoint="test-endpoint")
78+
assert response == {"success": True}
79+
mock_post_request.assert_called_once_with(
80+
url="https://custom_instance.api/v2/test-endpoint",
81+
payload=payload,
82+
headers=api.headers,
83+
)
84+
85+
86+
def test_api_post_request_invalid_payload():
87+
"""Tests that an invalid payload raises a ValueError."""
88+
api = API(url="https://custom_instance.api/v2")
89+
90+
with pytest.raises(ValueError):
91+
api.post(payload=["invalid", "payload"], endpoint="test-endpoint")
92+
93+
94+
def test_endpoint_initialization():
95+
"""Tests initializing an Endpoint with a valid API instance."""
96+
api = API(url="https://custom_instance.api/v2")
97+
endpoint = Endpoint(endpoint="node", api=api)
98+
99+
assert endpoint.endpoint == "node"
100+
assert endpoint.api is api
101+
102+
103+
def test_endpoint_repr():
104+
"""Tests the string representation of the Endpoint object."""
105+
api = API(url="https://custom.api/v2")
106+
endpoint = Endpoint(endpoint="node", api=api)
107+
108+
assert (
109+
repr(endpoint) == "<Node Endpoint using <API at https://custom.api/v2>>"
110+
)
111+
112+
113+
@patch("datacommons_client.endpoints.base.post_request")
114+
def test_endpoint_post_request(mock_post_request):
115+
"""Tests making a POST request using the Endpoint object."""
116+
mock_post_request.return_value = {"success": True}
117+
118+
api = API(url="https://custom.api/v2")
119+
endpoint = Endpoint(endpoint="node", api=api)
120+
payload = {"key": "value"}
121+
122+
response = endpoint.post(payload=payload)
123+
assert response == {"success": True}
124+
mock_post_request.assert_called_once_with(
125+
url="https://custom.api/v2/node",
126+
payload=payload,
127+
headers=api.headers,
128+
)
129+
130+
131+
def test_endpoint_post_request_invalid_payload():
132+
"""Tests that an invalid payload raises a ValueError in the Endpoint post method."""
133+
api = API(url="https://custom.api/v2")
134+
endpoint = Endpoint(endpoint="node", api=api)
135+
136+
with pytest.raises(ValueError):
137+
endpoint.post(payload=["invalid", "payload"])

datacommons_client/tests/endpoints/test_request_handling.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from datacommons_client.utils.error_hanlding import DCConnectionError
1010
from datacommons_client.utils.error_hanlding import DCStatusError
1111
from datacommons_client.utils.error_hanlding import InvalidDCInstanceError
12-
from datacommons_client.utils.request_handling import _check_instance_is_valid
12+
from datacommons_client.utils.request_handling import check_instance_is_valid
1313
from datacommons_client.utils.request_handling import _fetch_with_pagination
1414
from datacommons_client.utils.request_handling import _merge_values
1515
from datacommons_client.utils.request_handling import _recursively_merge_dicts
@@ -34,7 +34,7 @@ def test_check_instance_is_valid_request_exception(mock_get):
3434
"Request failed"
3535
)
3636
with pytest.raises(InvalidDCInstanceError):
37-
_check_instance_is_valid("https://invalid-instance")
37+
check_instance_is_valid("https://invalid-instance")
3838

3939

4040
@patch("requests.post")
@@ -77,7 +77,7 @@ def test_check_instance_is_valid_valid(mock_get):
7777
instance_url = "https://valid-instance"
7878

7979
# Assert that the instance URL is returned if it is valid
80-
assert _check_instance_is_valid(instance_url) == instance_url
80+
assert check_instance_is_valid(instance_url) == instance_url
8181
mock_get.assert_called_once_with(
8282
f"{instance_url}/node?nodes=country%2FGTM&property=->name"
8383
)
@@ -92,7 +92,7 @@ def test_check_instance_is_valid_invalid(mock_get):
9292
mock_get.return_value = mock_response
9393

9494
with pytest.raises(InvalidDCInstanceError):
95-
_check_instance_is_valid("https://invalid-instance")
95+
check_instance_is_valid("https://invalid-instance")
9696

9797

9898
@patch("requests.post")

datacommons_client/utils/request_handling.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@
44
from requests import exceptions
55
from requests import Response
66

7-
from datacommons_client.utils.error_hanlding import APIError
8-
from datacommons_client.utils.error_hanlding import DCAuthenticationError
9-
from datacommons_client.utils.error_hanlding import DCConnectionError
10-
from datacommons_client.utils.error_hanlding import DCStatusError
11-
from datacommons_client.utils.error_hanlding import InvalidDCInstanceError
7+
from datacommons_client.utils.error_handling import APIError
8+
from datacommons_client.utils.error_handling import DCAuthenticationError
9+
from datacommons_client.utils.error_handling import DCConnectionError
10+
from datacommons_client.utils.error_handling import DCStatusError
11+
from datacommons_client.utils.error_handling import InvalidDCInstanceError
1212

1313
BASE_DC_V2: str = "https://api.datacommons.org/v2"
1414
CUSTOM_DC_V2: str = "/core/api/v2"
1515

1616

17-
def _check_instance_is_valid(instance_url: str) -> str:
17+
def check_instance_is_valid(instance_url: str) -> str:
1818
"""Check that the given instance URL points to a valid Data Commons instance.
1919
2020
This function attempts a GET request against a known node in Data Commons to
@@ -70,7 +70,7 @@ def resolve_instance_url(dc_instance: str) -> str:
7070

7171
# Otherwise, validate the custom instance URL
7272
url = f"https://{dc_instance}{CUSTOM_DC_V2}"
73-
return _check_instance_is_valid(url)
73+
return check_instance_is_valid(url)
7474

7575

7676
def build_headers(api_key: str | None = None) -> dict[str, str]:
@@ -251,7 +251,7 @@ def post_request(
251251
headers: dict[str, str],
252252
max_pages: Optional[int] = None,
253253
) -> Dict[str, Any]:
254-
"""Send a POST request with optional pagination support and return a single dictionary.
254+
"""Send a POST request with optional pagination support and return a DCResponse.
255255
256256
Args:
257257
url: The target endpoint URL.

0 commit comments

Comments
 (0)