diff --git a/oura_api_client/api/client.py b/oura_api_client/api/client.py index 21ca4cd..e33ecae 100644 --- a/oura_api_client/api/client.py +++ b/oura_api_client/api/client.py @@ -32,14 +32,24 @@ class OuraClient: BASE_URL = "https://api.ouraring.com/v2" - def __init__(self, access_token: str, retry_config: Optional[RetryConfig] = None): + def __init__( + self, + access_token: str, + retry_config: Optional[RetryConfig] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None + ): """Initialize the Oura client with an access token. Args: access_token (str): Your Oura API personal access token retry_config (RetryConfig, optional): Configuration for retry behavior + client_id (str, optional): Client ID for webhook operations + client_secret (str, optional): Client secret for webhook operations """ self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret self.headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", @@ -73,6 +83,8 @@ def _make_request( params: Optional[Dict[str, Any]] = None, method: str = "GET", timeout: Optional[float] = 30.0, + json_data: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: """Make a request to the Oura API. @@ -81,6 +93,8 @@ def _make_request( params (dict, optional): Query parameters for the request method (str): HTTP method to use (default: GET) timeout (float, optional): Request timeout in seconds + json_data (dict, optional): JSON data for request body (POST/PUT/PATCH) + headers (dict, optional): Additional headers to merge with default headers Returns: dict: The JSON response from the API @@ -100,9 +114,9 @@ def _make_request( # Wrap the actual request in retry logic if enabled if self.retry_config.enabled: - return self._make_request_with_retry(url, method, params, timeout, endpoint) + return self._make_request_with_retry(url, method, params, timeout, endpoint, json_data, headers) else: - return self._make_single_request(url, method, params, timeout, endpoint) + return self._make_single_request(url, method, params, timeout, endpoint, json_data, headers) def _make_single_request( self, @@ -110,7 +124,9 @@ def _make_single_request( method: str, params: Optional[Dict[str, Any]], timeout: Optional[float], - endpoint: str + endpoint: str, + json_data: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: """Make a single HTTP request without retry logic. @@ -120,6 +136,8 @@ def _make_single_request( params: Query parameters timeout: Request timeout endpoint: Original endpoint for error context + json_data: JSON data for request body + headers: Additional headers to merge with default headers Returns: dict: The JSON response from the API @@ -128,15 +146,42 @@ def _make_single_request( OuraAPIError: If the request fails """ try: - if method.upper() == "GET": - response = requests.get(url, headers=self.headers, params=params, timeout=timeout) + # Merge headers with default client headers + request_headers = self.headers.copy() + if headers: + request_headers.update(headers) + + method_upper = method.upper() + if method_upper == "GET": + response = requests.get(url, headers=request_headers, params=params, timeout=timeout) + elif method_upper == "POST": + if json_data is not None: + response = requests.post(url, headers=request_headers, params=params, json=json_data, timeout=timeout) + else: + response = requests.post(url, headers=request_headers, params=params, timeout=timeout) + elif method_upper == "PUT": + if json_data is not None: + response = requests.put(url, headers=request_headers, params=params, json=json_data, timeout=timeout) + else: + response = requests.put(url, headers=request_headers, params=params, timeout=timeout) + elif method_upper == "PATCH": + if json_data is not None: + response = requests.patch(url, headers=request_headers, params=params, json=json_data, timeout=timeout) + else: + response = requests.patch(url, headers=request_headers, params=params, timeout=timeout) + elif method_upper == "DELETE": + response = requests.delete(url, headers=request_headers, params=params, timeout=timeout) else: - raise ValueError(f"HTTP method {method} is not supported yet") + raise ValueError(f"HTTP method {method} is not supported") # Check for HTTP errors if not response.ok: raise create_api_error(response, endpoint) + # Handle empty responses (e.g., for DELETE requests) + if response.status_code == 204 or not response.content.strip(): + return {} + return response.json() except requests.exceptions.Timeout as e: @@ -152,7 +197,9 @@ def _make_request_with_retry( method: str, params: Optional[Dict[str, Any]], timeout: Optional[float], - endpoint: str + endpoint: str, + json_data: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: """Make HTTP request with retry logic. @@ -162,6 +209,8 @@ def _make_request_with_retry( params: Query parameters timeout: Request timeout endpoint: Original endpoint for error context + json_data: JSON data for request body + headers: Additional headers to merge with default headers Returns: dict: The JSON response from the API @@ -176,6 +225,6 @@ def _make_request_with_retry( jitter=self.retry_config.jitter ) def make_request(): - return self._make_single_request(url, method, params, timeout, endpoint) + return self._make_single_request(url, method, params, timeout, endpoint, json_data, headers) return make_request() diff --git a/oura_api_client/api/webhook.py b/oura_api_client/api/webhook.py index 943a7b4..38d2fb8 100644 --- a/oura_api_client/api/webhook.py +++ b/oura_api_client/api/webhook.py @@ -14,7 +14,9 @@ class Webhook(BaseRouter): def _get_webhook_headers(self) -> dict: """Helper to construct headers for webhook requests.""" if (not hasattr(self.client, 'client_id') or - not hasattr(self.client, 'client_secret')): + not hasattr(self.client, 'client_secret') or + self.client.client_id is None or + self.client.client_secret is None): # This is a fallback or error case. Ideally, the OuraClient # should be initialized with client_id and client_secret if # webhook management is to be used. For now, we'll raise an error diff --git a/tests/test_client.py b/tests/test_client.py index c3df0ca..a755b55 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2670,3 +2670,318 @@ def test_get_vo2_max_document_not_found_404(self, mock_get): with self.assertRaises(OuraNotFoundError): self.client.vo2_max.get_vo2_max_document(document_id) + + +class TestWebhook(unittest.TestCase): + """Test cases for webhook operations.""" + + def setUp(self): + """Set up test client with webhook credentials.""" + self.client = OuraClient( + access_token="test_token", + client_id="test_client_id", + client_secret="test_client_secret" + ) + self.base_url = "https://api.ouraring.com/v2" + + @patch("requests.get") + def test_list_webhook_subscriptions(self, mock_get): + """Test listing webhook subscriptions.""" + # Mock response + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = [ + { + "id": "subscription_1", + "callback_url": "https://example.com/webhook", + "event_type": "create", + "data_type": "daily_activity", + "expiration_time": "2024-12-31T23:59:59+00:00" + } + ] + mock_get.return_value = mock_response + + response = self.client.webhook.list_webhook_subscriptions() + + self.assertEqual(len(response), 1) + self.assertEqual(response[0].id, "subscription_1") + self.assertEqual(response[0].callback_url, "https://example.com/webhook") + + mock_get.assert_called_once_with( + f"{self.base_url}/webhook/subscription", + headers={ + "Authorization": "Bearer test_token", + "Content-Type": "application/json", + "x-client-id": "test_client_id", + "x-client-secret": "test_client_secret" + }, + params=None, + timeout=30.0, + ) + + @patch("requests.post") + def test_create_webhook_subscription(self, mock_post): + """Test creating a webhook subscription.""" + from oura_api_client.models.webhook import WebhookOperation, ExtApiV2DataType + + # Mock response + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "id": "new_subscription_id", + "callback_url": "https://example.com/webhook", + "event_type": "create", + "data_type": "daily_activity", + "expiration_time": "2024-12-31T23:59:59+00:00" + } + mock_post.return_value = mock_response + + response = self.client.webhook.create_webhook_subscription( + callback_url="https://example.com/webhook", + event_type=WebhookOperation.CREATE, + data_type=ExtApiV2DataType.DAILY_ACTIVITY, + verification_token="test_token" + ) + + self.assertEqual(response.id, "new_subscription_id") + self.assertEqual(response.callback_url, "https://example.com/webhook") + + mock_post.assert_called_once() + call_args = mock_post.call_args + self.assertIn("json", call_args[1]) + self.assertEqual(call_args[1]["json"]["callback_url"], "https://example.com/webhook") + + @patch("requests.get") + def test_get_webhook_subscription(self, mock_get): + """Test getting a specific webhook subscription.""" + # Mock response + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "id": "subscription_1", + "callback_url": "https://example.com/webhook", + "event_type": "create", + "data_type": "daily_activity", + "expiration_time": "2024-12-31T23:59:59+00:00" + } + mock_get.return_value = mock_response + + response = self.client.webhook.get_webhook_subscription("subscription_1") + + self.assertEqual(response.id, "subscription_1") + self.assertEqual(response.callback_url, "https://example.com/webhook") + + mock_get.assert_called_once_with( + f"{self.base_url}/webhook/subscription/subscription_1", + headers={ + "Authorization": "Bearer test_token", + "Content-Type": "application/json", + "x-client-id": "test_client_id", + "x-client-secret": "test_client_secret" + }, + params=None, + timeout=30.0, + ) + + @patch("requests.put") + def test_update_webhook_subscription(self, mock_put): + """Test updating a webhook subscription.""" + from oura_api_client.models.webhook import WebhookOperation, ExtApiV2DataType + + # Mock response + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "id": "subscription_1", + "callback_url": "https://updated.example.com/webhook", + "event_type": "update", + "data_type": "daily_sleep", + "expiration_time": "2024-12-31T23:59:59+00:00" + } + mock_put.return_value = mock_response + + response = self.client.webhook.update_webhook_subscription( + subscription_id="subscription_1", + verification_token="test_token", + callback_url="https://updated.example.com/webhook", + event_type=WebhookOperation.UPDATE, + data_type=ExtApiV2DataType.DAILY_SLEEP + ) + + self.assertEqual(response.id, "subscription_1") + self.assertEqual(response.callback_url, "https://updated.example.com/webhook") + + mock_put.assert_called_once() + call_args = mock_put.call_args + self.assertIn("json", call_args[1]) + + @patch("requests.delete") + def test_delete_webhook_subscription(self, mock_delete): + """Test deleting a webhook subscription.""" + # Mock response + mock_response = MagicMock() + mock_response.ok = True + mock_response.status_code = 204 + mock_response.content = b"" + mock_delete.return_value = mock_response + + response = self.client.webhook.delete_webhook_subscription("subscription_1") + + self.assertIsNone(response) + + mock_delete.assert_called_once_with( + f"{self.base_url}/webhook/subscription/subscription_1", + headers={ + "Authorization": "Bearer test_token", + "Content-Type": "application/json", + "x-client-id": "test_client_id", + "x-client-secret": "test_client_secret" + }, + params=None, + timeout=30.0, + ) + + @patch("requests.put") + def test_renew_webhook_subscription(self, mock_put): + """Test renewing a webhook subscription.""" + # Mock response + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "id": "subscription_1", + "callback_url": "https://example.com/webhook", + "event_type": "create", + "data_type": "daily_activity", + "expiration_time": "2025-12-31T23:59:59+00:00" + } + mock_put.return_value = mock_response + + response = self.client.webhook.renew_webhook_subscription("subscription_1") + + self.assertEqual(response.id, "subscription_1") + self.assertEqual(response.expiration_time.year, 2025) + + mock_put.assert_called_once_with( + f"{self.base_url}/webhook/subscription/renew/subscription_1", + headers={ + "Authorization": "Bearer test_token", + "Content-Type": "application/json", + "x-client-id": "test_client_id", + "x-client-secret": "test_client_secret" + }, + params=None, + timeout=30.0, + ) + + @patch("requests.get") + def test_webhook_requires_credentials(self, mock_get): + """Test that webhook operations require client_id and client_secret.""" + client_without_creds = OuraClient(access_token="test_token") + + with self.assertRaises(ValueError) as context: + client_without_creds.webhook.list_webhook_subscriptions() + + self.assertIn("client_id and client_secret must be set", str(context.exception)) + + # The method should not have made any HTTP requests + mock_get.assert_not_called() + + +class TestHTTPMethods(unittest.TestCase): + """Test cases for HTTP method support in _make_request.""" + + def setUp(self): + """Set up test client.""" + self.client = OuraClient(access_token="test_token") + + @patch("requests.post") + def test_post_method_with_json_data(self, mock_post): + """Test POST method with JSON data.""" + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = {"success": True} + mock_post.return_value = mock_response + + result = self.client._make_request( + "/test", + method="POST", + json_data={"test": "data"} + ) + + self.assertEqual(result["success"], True) + mock_post.assert_called_once_with( + "https://api.ouraring.com/v2/test", + headers=self.client.headers, + params=None, + json={"test": "data"}, + timeout=30.0, + ) + + @patch("requests.put") + def test_put_method_without_json_data(self, mock_put): + """Test PUT method without JSON data.""" + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = {"updated": True} + mock_put.return_value = mock_response + + result = self.client._make_request("/test", method="PUT") + + self.assertEqual(result["updated"], True) + mock_put.assert_called_once_with( + "https://api.ouraring.com/v2/test", + headers=self.client.headers, + params=None, + timeout=30.0, + ) + + @patch("requests.delete") + def test_delete_method_empty_response(self, mock_delete): + """Test DELETE method with empty response.""" + mock_response = MagicMock() + mock_response.ok = True + mock_response.status_code = 204 + mock_response.content = b"" + mock_delete.return_value = mock_response + + result = self.client._make_request("/test", method="DELETE") + + self.assertEqual(result, {}) + mock_delete.assert_called_once() + + @patch("requests.patch") + def test_patch_method_with_headers(self, mock_patch): + """Test PATCH method with custom headers.""" + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = {"patched": True} + mock_patch.return_value = mock_response + + custom_headers = {"X-Custom": "value"} + result = self.client._make_request( + "/test", + method="PATCH", + json_data={"field": "value"}, + headers=custom_headers + ) + + self.assertEqual(result["patched"], True) + + # Check that custom headers are merged with default headers + expected_headers = self.client.headers.copy() + expected_headers.update(custom_headers) + + mock_patch.assert_called_once_with( + "https://api.ouraring.com/v2/test", + headers=expected_headers, + params=None, + json={"field": "value"}, + timeout=30.0, + ) + + def test_unsupported_method(self): + """Test that unsupported HTTP methods raise ValueError.""" + with self.assertRaises(ValueError) as context: + self.client._make_request("/test", method="TRACE") + + self.assertIn("HTTP method TRACE is not supported", str(context.exception))