Skip to content

Commit d9b21f5

Browse files
Fix for duplicate "fields" requirements in query/payload parameters (Webhooks)
1 parent 3fa89b9 commit d9b21f5

File tree

4 files changed

+76
-14
lines changed

4 files changed

+76
-14
lines changed

UPDATES.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,31 @@
1-
## 7/19/25 Update:
1+
## 8/14/25 Update:
2+
- **Added temporary fix for endpoints that have 'fields' requirements in both the Query Parameters and Payload Parameters**
3+
- **To be able to take advantage of the field builder validation for "fields='all'", I added an optional response_fields argument**
4+
- This will override any provided "fields" argument even if "fields" isn't a required payload paramater.
5+
```python
6+
response = client.post.webhooks(response_fields='all', fields='id,name,primary_phone_number', events=["created","updated"], model="contact", url='https://your-webhook-server.com/events')
7+
```
8+
**Returns:**
9+
```bash
10+
"data": {
11+
"id": 1234567,
12+
"etag": "\"longetagstring\"",
13+
"url": "https://your-webhook-server.com/events",
14+
"fields": "id,name,primary_phone_number",
15+
"shared_secret": "shared-secret",
16+
"model": "contact",
17+
"status": "pending",
18+
"events": [
19+
"created"
20+
],
21+
"expires_at": "2025-08-17T00:15:35-04:00",
22+
"created_at": "2025-08-14T00:15:35-04:00",
23+
"updated_at": "2025-08-14T00:15:35-04:00"
24+
}
25+
```
26+
**Using 'all' will not create a webhook subscription that provides 'all' fields in the actual webhook. For now they still need to be set manually**
27+
28+
## 7/19/25:
229
- **Removed model api as submodule**
330
- **Clio API Model Generator has been published to pypi and is available via:**
431
- pip install clio-api-model-generator

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
setup(
1212
name="clio-manage-api-client",
13-
version="0.1.2",
13+
version="0.1.3",
1414
author="Unigrated Partners",
1515
author_email="dev@unigratedpartners.com",
1616
description="Python Client for the Clio Manage API.",

src/clio_manage_python_client/classes/base.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import Dict, Any, Optional, Type, List, Union, Tuple, get_origin, get_args, Literal
33
from datetime import datetime
44
import typing_inspect
5+
from dataclasses import fields, is_dataclass
56

67
from ..configs import *
78
from ..utils.validate_fields import validate_field_string, build_id_field_string
@@ -213,6 +214,7 @@ def process_model(model: Type, data: Dict[str, Any]) -> Tuple[Dict[str, Any], Di
213214
errors = {}
214215

215216
for field_name, field_type in model.__annotations__.items():
217+
print(field_name, field_type)
216218
# Use the provided field name for validation; apply mappings after validation
217219
original_field_name = field_name
218220
mapped_field_name = MAPPINGS.get(field_name, field_name)
@@ -330,22 +332,50 @@ def _call_endpoint(self, metadata: dict, **kwargs):
330332
field_model = metadata.get("field_model")
331333
method = metadata["method"].upper()
332334

335+
use_field_model_for_fields = True # default
336+
337+
# If request_body_model exists, check if it has a field named 'fields'
338+
if request_body_model and "fields" in kwargs:
339+
for f in fields(request_body_model):
340+
# Check top-level
341+
if f.name == "fields":
342+
use_field_model_for_fields = False
343+
break
344+
345+
# Check nested dataclass
346+
if is_dataclass(f.type):
347+
nested_names = [sub_f.name for sub_f in fields(f.type)]
348+
if "fields" in nested_names:
349+
use_field_model_for_fields = False
350+
break
351+
352+
print("Use field model for 'fields'? ->", use_field_model_for_fields)
353+
333354
path = self._format_path(path, kwargs)
334355

335356
query_params = {}
336357
validation_errors = {"query": {}, "data": {}}
337358

338-
if field_model and "fields" in kwargs.keys():
359+
if field_model and "fields" in kwargs and use_field_model_for_fields:
339360
field_string = kwargs["fields"]
340361
if field_string == "all_ids":
341362
kwargs["fields"] = build_id_field_string(field_model)
342363
else:
343364
response = validate_field_string(field_model, field_string)
344365
kwargs["fields"] = response.get("valid_string")
345366

367+
# Validate 'response_fields' if provided, but only if we are NOT using the field model
368+
if "response_fields" in kwargs and not use_field_model_for_fields:
369+
field_string = kwargs["response_fields"]
370+
response = validate_field_string(field_model, field_string)
371+
kwargs["response_fields"] = response.get("valid_string")
372+
346373
# Validate query parameters
347374
if query_model:
348375
for field, field_type in query_model.__annotations__.items():
376+
if field == "fields" and not use_field_model_for_fields:
377+
continue
378+
349379
mapped_field = MAPPINGS.get(field, field)
350380

351381
value = kwargs.pop(mapped_field, kwargs.pop(field, None))
@@ -369,7 +399,12 @@ def _call_endpoint(self, metadata: dict, **kwargs):
369399
payload = payload_result.get("payload")
370400
if payload_result.get("errors"):
371401
validation_errors["data"].update(payload_result["errors"])
372-
402+
403+
# Reinject response_fields into query_params if present as "fields"
404+
response_fields = kwargs.get("response_fields")
405+
if response_fields:
406+
query_params["fields"] = response_fields
407+
373408
# If there are validation errors, return them
374409
if validation_errors["query"] or validation_errors["data"]:
375410
return {
@@ -380,8 +415,9 @@ def _call_endpoint(self, metadata: dict, **kwargs):
380415
"errors": validation_errors,
381416
}
382417

383-
url = BaseRequest.format_url(BaseRequest(self.base_url, self.base_path), path, query_params)
384-
return self.request_handler(url, method, query_params, payload, **kwargs)
418+
api_url = BaseRequest.format_url(BaseRequest(self.base_url, self.base_path), path, query_params)
419+
420+
return self.request_handler(api_url, method, query_params, payload, **kwargs)
385421

386422
def __getattr__(self, method_type: str):
387423
"""

src/clio_manage_python_client/client.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,22 +73,21 @@ def __getattr__(self, item: str):
7373
raise AttributeError(f"'Client' object has no attribute '{item}'")
7474

7575
# Syncronous Requests
76-
def _make_request(self, url: str, method: str, params: dict = None, payload: dict = None, return_all=False):
76+
def _make_request(self, api_url: str, method: str, params: dict = None, payload: dict = None, return_all=False):
7777
"""
7878
Makes the actual HTTP request based on the method.
7979
"""
8080
headers = {
8181
"Authorization": f"Bearer {self.access_token}",
8282
"Content-Type": "application/json",
8383
}
84-
print(params)
8584
params = params or {}
8685

8786
try:
8887
# Make the request
8988
response = requests.request(
9089
method=method.upper(),
91-
url=url,
90+
url=api_url,
9291
headers=headers,
9392
params=params,
9493
json=payload,
@@ -99,24 +98,24 @@ def _make_request(self, url: str, method: str, params: dict = None, payload: dic
9998
except requests.exceptions.RequestException as e:
10099
raise RuntimeError(f"HTTP request failed: {e}") from e
101100

102-
def _request_handler(self, url: str, method: str, params: dict = None, payload: dict = None, return_all=False, **kwargs):
101+
def _request_handler(self, api_url: str, method: str, params: dict = None, payload: dict = None, return_all=False, **kwargs):
103102
"""
104103
Handles requests, including support for paginated responses when return_all=True.
105104
"""
106-
endpoint = url.split(self.base_url)[-1].split("?")[0] # Extract endpoint from URL
105+
endpoint = api_url.split(self.base_url)[-1].split("?")[0] # Extract endpoint from URL
107106
params = params or {}
108107
# print(f'Return ALl: {return_all}')
109108
@self.rate_limiter(endpoint)
110109
def make_request():
111110
try:
112111
if method == "DOWNLOAD":
113-
response_obj = self._download_content(url, params)
112+
response_obj = self._download_content(api_url, params)
114113
self.rate_limiter.update_rate_limits(endpoint, response_obj.headers)
115114
return response_obj
116115

117116
if return_all is False:
118117
# Single request
119-
response_json, response_obj = self._make_request(url, method, params, payload)
118+
response_json, response_obj = self._make_request(api_url, method, params, payload)
120119
self.rate_limiter.update_rate_limits(endpoint, response_obj.headers)
121120
if self.response_handler:
122121
self.response_handler.add_response(response_obj, kwargs.get('call_metadata'))
@@ -125,7 +124,7 @@ def make_request():
125124
# Paginated request
126125
all_results = []
127126
next_page_token = None
128-
current_url = url
127+
current_url = api_url
129128

130129
while True:
131130

0 commit comments

Comments
 (0)