-
Notifications
You must be signed in to change notification settings - Fork 40
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
Showing
4 changed files
with
283 additions
and
12 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
from typing import Any, Dict, Optional | ||
|
||
from datacommons_client.utils.request_handling import build_headers | ||
from datacommons_client.utils.request_handling import check_instance_is_valid | ||
from datacommons_client.utils.request_handling import post_request | ||
from datacommons_client.utils.request_handling import resolve_instance_url | ||
|
||
|
||
class API: | ||
"""Represents a configured API interface to the Data Commons API. | ||
This class handles environment setup, resolving the base URL, building headers, | ||
or optionally using a fully qualified URL directly. It can be used standalone | ||
to interact with the API or in combination with Endpoint classes. | ||
""" | ||
|
||
def __init__( | ||
self, | ||
api_key: Optional[str] = None, | ||
dc_instance: Optional[str] = None, | ||
url: Optional[str] = None, | ||
): | ||
""" | ||
Initializes the API instance. | ||
Args: | ||
api_key: The API key for authentication. Defaults to None. | ||
dc_instance: The Data Commons instance domain. Ignored if `url` is provided. | ||
Defaults to 'datacommons.org' if both `url` and `dc_instance` are None. | ||
url: A fully qualified URL for the base API. This may be useful if more granular control | ||
of the API is required (for local development, for example). If provided, dc_instance` | ||
should not be provided. | ||
Raises: | ||
ValueError: If both `dc_instance` and `url` are provided. | ||
""" | ||
if dc_instance and url: | ||
raise ValueError("Cannot provide both `dc_instance` and `url`.") | ||
|
||
if not dc_instance and not url: | ||
dc_instance = "datacommons.org" | ||
|
||
self.headers = build_headers(api_key) | ||
|
||
if url is not None: | ||
# Use the given URL directly (strip trailing slash) | ||
self.base_url = check_instance_is_valid(url.rstrip("/")) | ||
else: | ||
# Resolve from dc_instance | ||
self.base_url = resolve_instance_url(dc_instance) | ||
|
||
def __repr__(self) -> str: | ||
"""Returns a readable representation of the API object. | ||
Indicates the base URL and if it's authenticated. | ||
Returns: | ||
str: A string representation of the API object. | ||
""" | ||
has_auth = " (Authenticated)" if "X-API-Key" in self.headers else "" | ||
return f"<API at {self.base_url}{has_auth}>" | ||
|
||
def post( | ||
self, payload: dict[str, Any], endpoint: Optional[str] = None | ||
) -> Dict[str, Any]: | ||
"""Makes a POST request using the configured API environment. | ||
If `endpoint` is provided, it will be appended to the base_url. Otherwise, | ||
it will just POST to the base URL. | ||
Args: | ||
payload: The JSON payload for the POST request. | ||
endpoint: An optional endpoint path to append to the base URL. | ||
Returns: | ||
A dictionary containing the merged response data. | ||
Raises: | ||
ValueError: If the payload is not a valid dictionary. | ||
""" | ||
if not isinstance(payload, dict): | ||
raise ValueError("Payload must be a dictionary.") | ||
|
||
url = ( | ||
self.base_url if endpoint is None else f"{self.base_url}/{endpoint}" | ||
) | ||
return post_request(url=url, payload=payload, headers=self.headers) | ||
|
||
|
||
class Endpoint: | ||
"""Represents a specific endpoint within the Data Commons API. | ||
This class leverages an API instance to make requests. It does not | ||
handle instance resolution or headers directly; that is delegated to the API instance. | ||
Attributes: | ||
endpoint (str): The endpoint path (e.g., 'node'). | ||
api (API): The API instance providing configuration and the `post` method. | ||
""" | ||
|
||
def __init__(self, endpoint: str, api: API): | ||
""" | ||
Initializes the Endpoint instance. | ||
Args: | ||
endpoint: The endpoint path (e.g., 'node'). | ||
api: An API instance that provides the environment configuration. | ||
""" | ||
self.endpoint = endpoint | ||
self.api = api | ||
|
||
def __repr__(self) -> str: | ||
"""Returns a readable representation of the Endpoint object. | ||
Shows the endpoint and underlying API configuration. | ||
Returns: | ||
str: A string representation of the Endpoint object. | ||
""" | ||
return f"<{self.endpoint.title()} Endpoint using {repr(self.api)}>" | ||
|
||
def post(self, payload: dict[str, Any]) -> Dict[str, Any]: | ||
"""Makes a POST request to the specified endpoint using the API instance. | ||
Args: | ||
payload: The JSON payload for the POST request. | ||
Returns: | ||
A dictionary with the merged API response data. | ||
Raises: | ||
ValueError: If the payload is not a valid dictionary. | ||
""" | ||
return self.api.post(payload=payload, endpoint=self.endpoint) |
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,137 @@ | ||
from unittest.mock import patch | ||
|
||
import pytest | ||
|
||
from datacommons_client.endpoints.base import API | ||
from datacommons_client.endpoints.base import Endpoint | ||
|
||
|
||
@patch("datacommons_client.endpoints.base.build_headers") | ||
@patch("datacommons_client.endpoints.base.resolve_instance_url") | ||
def test_api_initialization_default( | ||
mock_resolve_instance_url, mock_build_headers | ||
): | ||
"""Tests default API initialization with `datacommons.org` instance.""" | ||
mock_resolve_instance_url.return_value = "https://api.datacommons.org/v2" | ||
mock_build_headers.return_value = {"Content-Type": "application/json"} | ||
|
||
api = API() | ||
|
||
assert api.base_url == "https://api.datacommons.org/v2" | ||
assert api.headers == {"Content-Type": "application/json"} | ||
mock_resolve_instance_url.assert_called_once_with("datacommons.org") | ||
mock_build_headers.assert_called_once_with(None) | ||
|
||
|
||
@patch("datacommons_client.endpoints.base.build_headers") | ||
def test_api_initialization_with_url(mock_build_headers): | ||
"""Tests API initialization with a fully qualified URL.""" | ||
mock_build_headers.return_value = {"Content-Type": "application/json"} | ||
|
||
api = API(url="https://custom_instance.api/v2") | ||
assert api.base_url == "https://custom_instance.api/v2" | ||
assert api.headers == {"Content-Type": "application/json"} | ||
|
||
|
||
@patch("datacommons_client.endpoints.base.build_headers") | ||
@patch("datacommons_client.endpoints.base.resolve_instance_url") | ||
def test_api_initialization_with_dc_instance( | ||
mock_resolve_instance_url, mock_build_headers | ||
): | ||
"""Tests API initialization with a custom Data Commons instance.""" | ||
mock_resolve_instance_url.return_value = "https://custom-instance/api/v2" | ||
mock_build_headers.return_value = {"Content-Type": "application/json"} | ||
|
||
api = API(dc_instance="custom-instance") | ||
|
||
assert api.base_url == "https://custom-instance/api/v2" | ||
assert api.headers == {"Content-Type": "application/json"} | ||
mock_resolve_instance_url.assert_called_once_with("custom-instance") | ||
|
||
|
||
def test_api_initialization_invalid_args(): | ||
"""Tests API initialization with both `dc_instance` and `url` raises a ValueError.""" | ||
with pytest.raises(ValueError): | ||
API(dc_instance="custom-instance", url="https://custom.api/v2") | ||
|
||
|
||
def test_api_repr(): | ||
"""Tests the string representation of the API object.""" | ||
api = API(url="https://custom_instance.api/v2", api_key="test-key") | ||
assert ( | ||
repr(api) == "<API at https://custom_instance.api/v2 (Authenticated)>" | ||
) | ||
|
||
api = API(url="https://custom_instance.api/v2") | ||
assert repr(api) == "<API at https://custom_instance.api/v2>" | ||
|
||
|
||
@patch("datacommons_client.endpoints.base.post_request") | ||
def test_api_post_request(mock_post_request): | ||
"""Tests making a POST request using the API object.""" | ||
mock_post_request.return_value = {"success": True} | ||
|
||
api = API(url="https://custom_instance.api/v2") | ||
payload = {"key": "value"} | ||
|
||
response = api.post(payload=payload, endpoint="test-endpoint") | ||
assert response == {"success": True} | ||
mock_post_request.assert_called_once_with( | ||
url="https://custom_instance.api/v2/test-endpoint", | ||
payload=payload, | ||
headers=api.headers, | ||
) | ||
|
||
|
||
def test_api_post_request_invalid_payload(): | ||
"""Tests that an invalid payload raises a ValueError.""" | ||
api = API(url="https://custom_instance.api/v2") | ||
|
||
with pytest.raises(ValueError): | ||
api.post(payload=["invalid", "payload"], endpoint="test-endpoint") | ||
|
||
|
||
def test_endpoint_initialization(): | ||
"""Tests initializing an Endpoint with a valid API instance.""" | ||
api = API(url="https://custom_instance.api/v2") | ||
endpoint = Endpoint(endpoint="node", api=api) | ||
|
||
assert endpoint.endpoint == "node" | ||
assert endpoint.api is api | ||
|
||
|
||
def test_endpoint_repr(): | ||
"""Tests the string representation of the Endpoint object.""" | ||
api = API(url="https://custom.api/v2") | ||
endpoint = Endpoint(endpoint="node", api=api) | ||
|
||
assert ( | ||
repr(endpoint) == "<Node Endpoint using <API at https://custom.api/v2>>" | ||
) | ||
|
||
|
||
@patch("datacommons_client.endpoints.base.post_request") | ||
def test_endpoint_post_request(mock_post_request): | ||
"""Tests making a POST request using the Endpoint object.""" | ||
mock_post_request.return_value = {"success": True} | ||
|
||
api = API(url="https://custom.api/v2") | ||
endpoint = Endpoint(endpoint="node", api=api) | ||
payload = {"key": "value"} | ||
|
||
response = endpoint.post(payload=payload) | ||
assert response == {"success": True} | ||
mock_post_request.assert_called_once_with( | ||
url="https://custom.api/v2/node", | ||
payload=payload, | ||
headers=api.headers, | ||
) | ||
|
||
|
||
def test_endpoint_post_request_invalid_payload(): | ||
"""Tests that an invalid payload raises a ValueError in the Endpoint post method.""" | ||
api = API(url="https://custom.api/v2") | ||
endpoint = Endpoint(endpoint="node", api=api) | ||
|
||
with pytest.raises(ValueError): | ||
endpoint.post(payload=["invalid", "payload"]) |
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