Skip to content

Commit

Permalink
Implement API and EndPoint classes to interact with Data Commons…
Browse files Browse the repository at this point in the history
… 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
jm-rivera authored Jan 17, 2025
1 parent 0c1f1a5 commit 57ea77c
Show file tree
Hide file tree
Showing 4 changed files with 283 additions and 12 deletions.
134 changes: 134 additions & 0 deletions datacommons_client/endpoints/base.py
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)
137 changes: 137 additions & 0 deletions datacommons_client/tests/endpoints/test_base.py
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"])
8 changes: 4 additions & 4 deletions datacommons_client/tests/endpoints/test_request_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from datacommons_client.utils.error_hanlding import DCConnectionError
from datacommons_client.utils.error_hanlding import DCStatusError
from datacommons_client.utils.error_hanlding import InvalidDCInstanceError
from datacommons_client.utils.request_handling import _check_instance_is_valid
from datacommons_client.utils.request_handling import check_instance_is_valid
from datacommons_client.utils.request_handling import _fetch_with_pagination
from datacommons_client.utils.request_handling import _merge_values
from datacommons_client.utils.request_handling import _recursively_merge_dicts
Expand All @@ -34,7 +34,7 @@ def test_check_instance_is_valid_request_exception(mock_get):
"Request failed"
)
with pytest.raises(InvalidDCInstanceError):
_check_instance_is_valid("https://invalid-instance")
check_instance_is_valid("https://invalid-instance")


@patch("requests.post")
Expand Down Expand Up @@ -77,7 +77,7 @@ def test_check_instance_is_valid_valid(mock_get):
instance_url = "https://valid-instance"

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

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


@patch("requests.post")
Expand Down
16 changes: 8 additions & 8 deletions datacommons_client/utils/request_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@
from requests import exceptions
from requests import Response

from datacommons_client.utils.error_hanlding import APIError
from datacommons_client.utils.error_hanlding import DCAuthenticationError
from datacommons_client.utils.error_hanlding import DCConnectionError
from datacommons_client.utils.error_hanlding import DCStatusError
from datacommons_client.utils.error_hanlding import InvalidDCInstanceError
from datacommons_client.utils.error_handling import APIError
from datacommons_client.utils.error_handling import DCAuthenticationError
from datacommons_client.utils.error_handling import DCConnectionError
from datacommons_client.utils.error_handling import DCStatusError
from datacommons_client.utils.error_handling import InvalidDCInstanceError

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


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

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


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

0 comments on commit 57ea77c

Please sign in to comment.