Skip to content

Commit f0df24c

Browse files
committed
Refactor curl debug functionality into CurlDebugMixin
Extract curl command generation functionality from BaseResource to a dedicated mixin in utils/curl_debug_mixin.py. This improves code organization by: 1. Moving a single-purpose feature into its own module 2. Simplifying the BaseResource class 3. Making the debug functionality more reusable Also updated tests to maintain 100% coverage.
1 parent 642d7f2 commit f0df24c

File tree

5 files changed

+218
-116
lines changed

5 files changed

+218
-116
lines changed

TODO.md

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010

1111
- Rename to `_base`? Files it first, makes it clearer that everything in it is
1212
private
13-
- Move the methods for building `curl` commands into a mixin? It's a lot of
14-
code for an isolated and tightly scoped feature.
13+
- ✅ Move the methods for building `curl` commands into a mixin.
1514
- refactor `_make_request`.
1615
- do we need both `data` and `json`? Also, could we simplify a lot of typing
1716
if we separated GET, POST, and DELETE methods? Maybe even a separate,
@@ -24,26 +23,10 @@
2423
- Creat and Test that all methods have an alias in `Client` and that the
2524
signatures match
2625

27-
```python
28-
if not food_id and not (food_name and calories):
29-
raise ClientValidationException(
30-
"Must provide either food_id or (food_name and calories)"
31-
)
32-
```
33-
34-
- nutrition.py:
35-
- It doesn't seem like this should be passing tests when CALORIES is not an int:
36-
37-
```python
38-
# Handle both enum and string nutritional values
39-
for key, value in nutritional_values.items():
40-
if isinstance(key, NutritionalValue):
41-
params[key.value] = float(value)
42-
else:
43-
params[str(key)] = float(value)
44-
```
26+
- CI:
4527

46-
see: test_create_food_calories_from_fat_must_be_integer(nutrition_resource)
28+
* Read and implement:
29+
https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/configuring-advanced-setup-for-code-scanning#configuring-advanced-setup-for-code-scanning-with-codeql
4730

4831
- exceptions.py Consider:
4932
- Add automatic token refresh for ExpiredTokenException

fitbit_client/resources/base.py

Lines changed: 7 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import Optional
1212
from typing import Set
1313
from typing import cast
14+
from typing import overload
1415
from urllib.parse import urlencode
1516

1617
# Third party imports
@@ -21,7 +22,11 @@
2122
from fitbit_client.exceptions import ERROR_TYPE_EXCEPTIONS
2223
from fitbit_client.exceptions import FitbitAPIException
2324
from fitbit_client.exceptions import STATUS_CODE_EXCEPTIONS
25+
from fitbit_client.utils.curl_debug_mixin import CurlDebugMixin
26+
from fitbit_client.utils.types import JSONDict
27+
from fitbit_client.utils.types import JSONList
2428
from fitbit_client.utils.types import JSONType
29+
from fitbit_client.utils.types import ParamDict
2530

2631
# Constants for important fields to track in logging
2732
IMPORTANT_RESPONSE_FIELDS: Set[str] = {
@@ -41,7 +46,7 @@
4146
}
4247

4348

44-
class BaseResource:
49+
class BaseResource(CurlDebugMixin):
4550
"""Provides foundational functionality for all Fitbit API resource classes.
4651
4752
The BaseResource class implements core functionality that all specific resource
@@ -61,7 +66,7 @@ class BaseResource:
6166
- Request handling with comprehensive error management
6267
- Response parsing with type safety
6368
- Detailed logging of requests, responses, and errors
64-
- Debug capabilities for API troubleshooting
69+
- Debug capabilities for API troubleshooting (via CurlDebugMixin)
6570
- OAuth2 authentication management
6671
6772
Note:
@@ -189,69 +194,6 @@ def _get_calling_method(self) -> str:
189194
frame = frame.f_back
190195
return "unknown"
191196

192-
def _build_curl_command(
193-
self,
194-
url: str,
195-
http_method: str,
196-
data: Optional[Dict[str, Any]] = None,
197-
json: Optional[Dict[str, Any]] = None,
198-
params: Optional[Dict[str, Any]] = None,
199-
) -> str:
200-
"""
201-
Build a curl command string for debugging API requests.
202-
203-
Args:
204-
url: Full API URL
205-
http_method: HTTP method (GET, POST, DELETE)
206-
data: Optional form data for POST requests
207-
json: Optional JSON data for POST requests
208-
params: Optional query parameters for GET requests
209-
210-
Returns:
211-
Complete curl command as a multi-line string
212-
213-
The generated command includes:
214-
- The HTTP method (for non-GET requests)
215-
- Authorization header with OAuth token
216-
- Request body (if data or json is provided)
217-
- Query parameters (if provided)
218-
219-
The command is formatted with line continuations for readability and
220-
can be copied directly into a terminal for testing.
221-
222-
Example output:
223-
curl \\
224-
-X POST \\
225-
-H "Authorization: Bearer <token>" \\
226-
-H "Content-Type: application/json" \\
227-
-d '{"name": "value"}' \\
228-
'https://api.fitbit.com/1/user/-/foods/log.json'
229-
"""
230-
# Start with base command
231-
cmd_parts = ["curl -v"]
232-
233-
# Add method
234-
if http_method != "GET":
235-
cmd_parts.append(f"-X {http_method}")
236-
237-
# Add auth header
238-
cmd_parts.append(f'-H "Authorization: Bearer {self.oauth.token["access_token"]}"')
239-
240-
# Add data if present
241-
if json:
242-
cmd_parts.append(f"-d '{dumps(json)}'")
243-
cmd_parts.append('-H "Content-Type: application/json"')
244-
elif data:
245-
cmd_parts.append(f"-d '{urlencode(data)}'")
246-
cmd_parts.append('-H "Content-Type: application/x-www-form-urlencoded"')
247-
248-
# Add URL with parameters if present
249-
if params:
250-
url = f"{url}?{urlencode(params)}"
251-
cmd_parts.append(f"'{url}'")
252-
253-
return " \\\n ".join(cmd_parts)
254-
255197
def _log_response(
256198
self, calling_method: str, endpoint: str, response: Response, content: Optional[Dict] = None
257199
) -> None:
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# fitbit_client/utils/curl_debug_mixin.py
2+
3+
"""
4+
Mixin for generating curl commands for API debugging.
5+
"""
6+
7+
# Standard library imports
8+
from json import dumps
9+
from typing import Any
10+
from typing import Dict
11+
from typing import Optional
12+
from urllib.parse import urlencode
13+
14+
15+
class CurlDebugMixin:
16+
"""Mixin that provides curl command generation for debugging API requests.
17+
18+
This mixin can be used with API client classes to add the ability to generate
19+
equivalent curl commands for debugging purposes. It helps with:
20+
- Testing API endpoints directly
21+
- Debugging authentication/scope issues
22+
- Verifying request structure
23+
- Troubleshooting permission problems
24+
"""
25+
26+
def _build_curl_command(
27+
self,
28+
url: str,
29+
http_method: str,
30+
data: Optional[Dict[str, Any]] = None,
31+
json: Optional[Dict[str, Any]] = None,
32+
params: Optional[Dict[str, Any]] = None,
33+
) -> str:
34+
"""
35+
Build a curl command string for debugging API requests.
36+
37+
Args:
38+
url: Full API URL
39+
http_method: HTTP method (GET, POST, DELETE)
40+
data: Optional form data for POST requests
41+
json: Optional JSON data for POST requests
42+
params: Optional query parameters for GET requests
43+
44+
Returns:
45+
Complete curl command as a multi-line string
46+
47+
The generated command includes:
48+
- The HTTP method (for non-GET requests)
49+
- Authorization header with OAuth token
50+
- Request body (if data or json is provided)
51+
- Query parameters (if provided)
52+
53+
The command is formatted with line continuations for readability and
54+
can be copied directly into a terminal for testing.
55+
56+
Example output:
57+
curl \\
58+
-X POST \\
59+
-H "Authorization: Bearer <token>" \\
60+
-H "Content-Type: application/json" \\
61+
-d '{"name": "value"}' \\
62+
'https://api.fitbit.com/1/user/-/foods/log.json'
63+
"""
64+
# Start with base command
65+
cmd_parts = ["curl -v"]
66+
67+
# Add method
68+
if http_method != "GET":
69+
cmd_parts.append(f"-X {http_method}")
70+
71+
# Add auth header
72+
cmd_parts.append(f'-H "Authorization: Bearer {self.oauth.token["access_token"]}"')
73+
74+
# Add data if present
75+
if json:
76+
cmd_parts.append(f"-d '{dumps(json)}'")
77+
cmd_parts.append('-H "Content-Type: application/json"')
78+
elif data:
79+
cmd_parts.append(f"-d '{urlencode(data)}'")
80+
cmd_parts.append('-H "Content-Type: application/x-www-form-urlencoded"')
81+
82+
# Add URL with parameters if present
83+
if params:
84+
url = f"{url}?{urlencode(params)}"
85+
cmd_parts.append(f"'{url}'")
86+
87+
return " \\\n ".join(cmd_parts)

tests/resources/test_base.py

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -247,38 +247,10 @@ def test_log_response_for_error_without_content(base_resource, mock_logger):
247247

248248

249249
# -----------------------------------------------------------------------------
250-
# 6. Debug Curl Command Generation
250+
# 6. Debug Mode Testing
251251
# -----------------------------------------------------------------------------
252252

253-
254-
def test_build_curl_command_with_json_data(base_resource):
255-
"""Test generating curl command with JSON data"""
256-
# This tests lines 217-218 in base.py
257-
base_resource.oauth.token = {"access_token": "test_token"}
258-
259-
json_data = {"name": "Test Activity", "type": "run", "duration": 3600}
260-
result = base_resource._build_curl_command(
261-
url="https://api.fitbit.com/1/user/-/activities.json", http_method="POST", json=json_data
262-
)
263-
264-
# Assert command contains JSON data and correct header
265-
assert '-d \'{"name": "Test Activity", "type": "run", "duration": 3600}\'' in result
266-
assert '-H "Content-Type: application/json"' in result
267-
268-
269-
def test_build_curl_command_with_form_data(base_resource):
270-
"""Test generating curl command with form data"""
271-
# This tests lines 220-221 in base.py
272-
base_resource.oauth.token = {"access_token": "test_token"}
273-
274-
form_data = {"date": "2023-01-01", "foodId": "12345", "amount": "1", "mealTypeId": "1"}
275-
result = base_resource._build_curl_command(
276-
url="https://api.fitbit.com/1/user/-/foods/log.json", http_method="POST", data=form_data
277-
)
278-
279-
# Assert command contains form data and correct header
280-
assert "-d 'date=2023-01-01&foodId=12345&amount=1&mealTypeId=1'" in result
281-
assert '-H "Content-Type: application/x-www-form-urlencoded"' in result
253+
# Debug mode tests are now in tests/utils/test_curl_debug_mixin.py
282254

283255

284256
# -----------------------------------------------------------------------------
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# tests/utils/test_curl_debug_mixin.py
2+
3+
"""Tests for CurlDebugMixin"""
4+
5+
# Standard library imports
6+
from unittest.mock import Mock
7+
from unittest.mock import patch
8+
9+
# Third party imports
10+
from pytest import fixture
11+
12+
# Local imports
13+
from fitbit_client.utils.curl_debug_mixin import CurlDebugMixin
14+
15+
16+
# Create a test class that uses the mixin for debug testing
17+
class TestResource(CurlDebugMixin):
18+
"""Test class that uses CurlDebugMixin"""
19+
20+
def __init__(self):
21+
self.oauth = Mock()
22+
self.oauth.token = {"access_token": "test_token"}
23+
24+
def make_debug_request(self, debug=False):
25+
"""Test method that simulates _make_request with debug mode"""
26+
url = "https://api.fitbit.com/1/user/-/test/endpoint"
27+
28+
if debug:
29+
curl_command = self._build_curl_command(
30+
url=url, http_method="GET", params={"param1": "value1"}
31+
)
32+
print(f"\n# Debug curl command:")
33+
print(curl_command)
34+
print()
35+
return None
36+
37+
return {"success": True}
38+
39+
40+
@fixture
41+
def curl_debug_mixin():
42+
"""Fixture for CurlDebugMixin with mocked OAuth session"""
43+
mixin = CurlDebugMixin()
44+
mixin.oauth = Mock()
45+
mixin.oauth.token = {"access_token": "test_token"}
46+
return mixin
47+
48+
49+
def test_build_curl_command_with_json_data(curl_debug_mixin):
50+
"""Test generating curl command with JSON data"""
51+
json_data = {"name": "Test Activity", "type": "run", "duration": 3600}
52+
result = curl_debug_mixin._build_curl_command(
53+
url="https://api.fitbit.com/1/user/-/activities.json", http_method="POST", json=json_data
54+
)
55+
56+
# Assert command contains JSON data and correct header
57+
assert '-d \'{"name": "Test Activity", "type": "run", "duration": 3600}\'' in result
58+
assert '-H "Content-Type: application/json"' in result
59+
assert "-X POST" in result
60+
assert "curl -v" in result
61+
assert '-H "Authorization: Bearer test_token"' in result
62+
assert "'https://api.fitbit.com/1/user/-/activities.json'" in result
63+
64+
65+
def test_build_curl_command_with_form_data(curl_debug_mixin):
66+
"""Test generating curl command with form data"""
67+
form_data = {"date": "2023-01-01", "foodId": "12345", "amount": "1", "mealTypeId": "1"}
68+
result = curl_debug_mixin._build_curl_command(
69+
url="https://api.fitbit.com/1/user/-/foods/log.json", http_method="POST", data=form_data
70+
)
71+
72+
# Assert command contains form data and correct header
73+
assert "-d 'date=2023-01-01&foodId=12345&amount=1&mealTypeId=1'" in result
74+
assert '-H "Content-Type: application/x-www-form-urlencoded"' in result
75+
assert "-X POST" in result
76+
77+
78+
def test_build_curl_command_with_get_params(curl_debug_mixin):
79+
"""Test generating curl command with GET parameters"""
80+
params = {"date": "2023-01-01", "offset": "0", "limit": "10"}
81+
result = curl_debug_mixin._build_curl_command(
82+
url="https://api.fitbit.com/1/user/-/activities/list.json", http_method="GET", params=params
83+
)
84+
85+
# Assert command doesn't have -X GET but has parameters in URL
86+
assert "-X GET" not in result
87+
assert "?date=2023-01-01&offset=0&limit=10" in result
88+
89+
90+
def test_build_curl_command_with_delete(curl_debug_mixin):
91+
"""Test generating curl command for DELETE request"""
92+
result = curl_debug_mixin._build_curl_command(
93+
url="https://api.fitbit.com/1/user/-/foods/log/123456.json", http_method="DELETE"
94+
)
95+
96+
# Assert command has DELETE method
97+
assert "-X DELETE" in result
98+
assert "curl -v" in result
99+
assert '-H "Authorization: Bearer test_token"' in result
100+
101+
102+
def test_debug_mode_integration(capsys):
103+
"""Test debug mode integration with a resource class"""
104+
# Create test resource
105+
resource = TestResource()
106+
107+
# Call make_debug_request with debug=True
108+
result = resource.make_debug_request(debug=True)
109+
110+
# Capture stdout
111+
captured = capsys.readouterr()
112+
113+
# Verify results
114+
assert result is None
115+
assert "curl" in captured.out
116+
assert "test/endpoint" in captured.out
117+
assert "param1=value1" in captured.out
118+
assert "test_token" in captured.out

0 commit comments

Comments
 (0)