Skip to content

Commit eee4d8b

Browse files
committed
fix: send attributes and excludedAttributes as comma-separated values
1 parent c1287a2 commit eee4d8b

4 files changed

Lines changed: 65 additions & 5 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
Changelog
22
=========
33

4+
[Unreleased]
5+
------------
6+
7+
Fixed
8+
^^^^^
9+
- ``attributes`` and ``excludedAttributes`` are sent in the query string as comma-separated
10+
values, per :rfc:`7644#section-3.9`. They used to be sent as repeated parameters, and some
11+
servers only read the first value.
12+
413
[0.11.1] - 2026-10-01
514
---------------------
615

‎scim2_client/client.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,18 @@ def _resource_url(endpoint: str, id: str) -> str:
175175
return f"{endpoint}/{quote(id, safe=_PATH_SEGMENT_SAFE)}"
176176

177177

178+
def _join_list_values(params: dict[str, Any]) -> dict[str, Any]:
179+
"""Send the list query parameters as comma-separated values (RFC 7644 §3.9).
180+
181+
Empty lists are left out of the query string.
182+
"""
183+
return {
184+
key: ",".join(map(str, value)) if isinstance(value, list) else value
185+
for key, value in params.items()
186+
if value != []
187+
}
188+
189+
178190
@dataclass
179191
class RequestPayload:
180192
request_kwargs: dict[str, Any]
@@ -1100,7 +1112,7 @@ def _request_kwargs(self, method: str, req: RequestPayload) -> dict[str, Any]:
11001112
"""Build the arguments a prepared request is sent with."""
11011113
body: dict[str, Any] = {}
11021114
if method == "GET" and req.payload:
1103-
body = self._query_kwargs(req.payload)
1115+
body = self._query_kwargs(_join_list_values(req.payload))
11041116

11051117
elif method not in ("GET", "DELETE"):
11061118
body = {"json": req.payload}

‎tests/engines/test_werkzeug.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,25 @@ def test_werkzeug_query_with_attributes(scim_client):
105105
assert result.title is None
106106

107107

108+
def test_werkzeug_query_list_parameters_are_comma_separated():
109+
"""List query parameters are sent as comma-separated values (RFC 7644 §3.9)."""
110+
query_strings = []
111+
112+
@Request.application
113+
def application(request):
114+
query_strings.append(request.query_string.decode())
115+
return Response(
116+
'{"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "totalResults": 0}',
117+
content_type="application/scim+json",
118+
)
119+
120+
scim_client = TestSCIMClient(
121+
client=Client(application), provider=ScimProvider(models=[User])
122+
)
123+
scim_client.query(User, SearchRequest(attributes=["displayName", "title"]))
124+
assert query_strings == ["attributes=displayName%2Ctitle"]
125+
126+
108127
def test_no_json():
109128
"""Test that pages that do not return JSON raise an UnexpectedContentFormatException error."""
110129

‎tests/test_query.py‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -638,7 +638,7 @@ def test_response_bad_content_type(sync_client):
638638

639639

640640
def test_search_request(httpserver, sync_client):
641-
query_string = "attributes=userName&attributes=displayName&filter=userName+Eq+%22john%22&sortBy=userName&sortOrder=ascending&startIndex=1&count=10"
641+
query_string = "attributes=userName%2CdisplayName&filter=userName+Eq+%22john%22&sortBy=userName&sortOrder=ascending&startIndex=1&count=10"
642642

643643
httpserver.expect_request(
644644
"/Users/with-qs", query_string=query_string
@@ -673,7 +673,7 @@ def test_search_request(httpserver, sync_client):
673673

674674
def test_query_parameters(httpserver, sync_client):
675675
"""ResponseParameters can be used instead of SearchRequest for single-resource queries."""
676-
query_string = "attributes=userName&attributes=displayName"
676+
query_string = "attributes=userName%2CdisplayName"
677677

678678
httpserver.expect_request(
679679
"/Users/with-rp", query_string=query_string
@@ -698,6 +698,23 @@ def test_query_parameters(httpserver, sync_client):
698698
assert response.id == "with-rp"
699699

700700

701+
def test_query_empty_list_parameters_are_not_sent(httpserver, sync_client):
702+
"""Empty attribute lists are left out of the query string."""
703+
httpserver.expect_request(
704+
"/Users/with-empty", query_string="attributes=userName"
705+
).respond_with_json(
706+
{
707+
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
708+
"id": "with-empty",
709+
"userName": "bjensen@example.com",
710+
},
711+
status=200,
712+
)
713+
params = ResponseParameters(attributes=["userName"], excluded_attributes=[])
714+
response = sync_client.query(User, "with-empty", params)
715+
assert response.id == "with-empty"
716+
717+
701718
def test_query_parameters_dict(httpserver, sync_client):
702719
"""Query parameters given as a dict are sent to the server."""
703720
sync_client.query(
@@ -752,8 +769,11 @@ def test_invalid_query_parameters_dict(sync_client):
752769

753770

754771
def test_query_dont_check_request_payload(httpserver, sync_client):
755-
"""Raw dict payloads are forwarded as-is when check_request_payload is False."""
756-
query_string = "attributes=userName&attributes=displayName&excluded_attributes=timezone&excluded_attributes=phoneNumbers&filter=userName+Eq+%22john%22&sort_by=userName&sort_order=ascending&start_index=1&count=10"
772+
"""Raw dict payloads are forwarded unvalidated when check_request_payload is False.
773+
774+
List values are sent as comma-separated values.
775+
"""
776+
query_string = "attributes=userName%2CdisplayName&excluded_attributes=timezone%2CphoneNumbers&filter=userName+Eq+%22john%22&sort_by=userName&sort_order=ascending&start_index=1&count=10"
757777

758778
httpserver.expect_request(
759779
"/Users/with-qs", query_string=query_string

0 commit comments

Comments
 (0)