Skip to content

Commit ffb395b

Browse files
authored
feat: attribute inclusion/exclusions overhaul (#136)
- ListResponse accepts attributes and excluded_attributes parameters - Extract attributes/excluded_attributes logic from SearchRequest to ResponseParameters - attributes and excluded_attributes can be comma separated strings
1 parent 1cc45fd commit ffb395b

17 files changed

+415
-140
lines changed

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+
[0.7.0] - Unreleased
5+
--------------------
6+
7+
Added
8+
^^^^^
9+
- :class:`~scim2_models.ListResponse` ``model_dump`` and ``model_dump_json`` now accept ``attributes`` and ``excluded_attributes`` parameters. :issue:`59`
10+
- New :class:`~scim2_models.ResponseParameters` model for :rfc:`RFC7644 §3.9 <7644#section-3.9>` ``attributes`` and ``excludedAttributes`` query parameters. :class:`~scim2_models.SearchRequest` inherits from it.
11+
- :class:`~scim2_models.ResponseParameters` and :class:`~scim2_models.SearchRequest` accept comma-separated strings for ``attributes`` and ``excludedAttributes``.
12+
413
[0.6.6] - 2026-03-12
514
--------------------
615

doc/guides/_examples/django_example.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from scim2_models import Error
1414
from scim2_models import ListResponse
1515
from scim2_models import PatchOp
16+
from scim2_models import ResponseParameters
1617
from scim2_models import SearchRequest
1718
from scim2_models import UniquenessException
1819
from scim2_models import User
@@ -86,9 +87,18 @@ class UserView(View):
8687
"""Handle GET, PATCH and DELETE on one SCIM user resource."""
8788

8889
def get(self, request, app_record):
90+
try:
91+
req = ResponseParameters.model_validate(request.GET.dict())
92+
except ValidationError as error:
93+
return scim_validation_error(error)
94+
8995
scim_user = to_scim_user(app_record)
9096
return scim_response(
91-
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
97+
scim_user.model_dump_json(
98+
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
99+
attributes=req.attributes,
100+
excluded_attributes=req.excluded_attributes,
101+
)
92102
)
93103

94104
def delete(self, request, app_record):
@@ -126,9 +136,10 @@ class UsersView(View):
126136

127137
def get(self, request):
128138
try:
129-
req = SearchRequest.model_validate(request.GET)
139+
req = SearchRequest.model_validate(request.GET.dict())
130140
except ValidationError as error:
131141
return scim_validation_error(error)
142+
132143
all_records = list_records()
133144
page = all_records[req.start_index_0 : req.stop_index_0]
134145
resources = [to_scim_user(record) for record in page]
@@ -139,7 +150,11 @@ def get(self, request):
139150
resources=resources,
140151
)
141152
return scim_response(
142-
response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
153+
response.model_dump_json(
154+
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
155+
attributes=req.attributes,
156+
excluded_attributes=req.excluded_attributes,
157+
)
143158
)
144159

145160
def post(self, request):

doc/guides/_examples/flask_example.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from scim2_models import Error
1111
from scim2_models import ListResponse
1212
from scim2_models import PatchOp
13+
from scim2_models import ResponseParameters
1314
from scim2_models import SearchRequest
1415
from scim2_models import UniquenessException
1516
from scim2_models import User
@@ -84,9 +85,14 @@ def handle_value_error(error):
8485
@bp.get("/Users/<user:app_record>")
8586
def get_user(app_record):
8687
"""Return one SCIM user."""
88+
req = ResponseParameters.model_validate(request.args.to_dict())
8789
scim_user = to_scim_user(app_record)
8890
return (
89-
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
91+
scim_user.model_dump_json(
92+
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
93+
attributes=req.attributes,
94+
excluded_attributes=req.excluded_attributes,
95+
),
9096
HTTPStatus.OK,
9197
)
9298
# -- get-user-end --
@@ -128,7 +134,7 @@ def delete_user(app_record):
128134
@bp.get("/Users")
129135
def list_users():
130136
"""Return one page of users as a SCIM ListResponse."""
131-
req = SearchRequest.model_validate(request.args)
137+
req = SearchRequest.model_validate(request.args.to_dict())
132138
all_records = list_records()
133139
page = all_records[req.start_index_0 : req.stop_index_0]
134140
resources = [to_scim_user(record) for record in page]
@@ -139,7 +145,11 @@ def list_users():
139145
resources=resources,
140146
)
141147
return (
142-
response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
148+
response.model_dump_json(
149+
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
150+
attributes=req.attributes,
151+
excluded_attributes=req.excluded_attributes,
152+
),
143153
HTTPStatus.OK,
144154
)
145155
# -- list-users-end --

doc/guides/django.rst

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,9 @@ Single resource
104104
^^^^^^^^^^^^^^^
105105

106106
``UserView`` handles ``GET``, ``PATCH`` and ``DELETE`` on ``/Users/<id>``.
107-
For ``GET``, convert the native record to a SCIM resource and serialize with
108-
:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`.
107+
For ``GET``, parse query parameters with :class:`~scim2_models.ResponseParameters` to honour the
108+
``attributes`` and ``excludedAttributes`` query parameters, convert the native record to a
109+
SCIM resource, and serialize with :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`.
109110
For ``DELETE``, remove the record and return an empty 204 response.
110111
For ``PATCH``, validate the payload with :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`,
111112
apply it with :meth:`~scim2_models.PatchOp.patch` (generic, works with any resource type),
@@ -121,9 +122,13 @@ Collection
121122
^^^^^^^^^^
122123

123124
``UsersView`` handles ``GET /Users`` and ``POST /Users``.
124-
For ``GET``, parse pagination parameters with :class:`~scim2_models.SearchRequest`, slice
125-
the store, then wrap the page in a :class:`~scim2_models.ListResponse` serialized with
125+
For ``GET``, parse pagination and filtering parameters with
126+
:class:`~scim2_models.SearchRequest`, slice the store, then wrap the page in a
127+
:class:`~scim2_models.ListResponse` serialized with
126128
:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`.
129+
``req.attributes`` and ``req.excluded_attributes`` are passed to
130+
:meth:`~scim2_models.ListResponse.model_dump_json` to apply the ``attributes`` and
131+
``excludedAttributes`` query parameters to each embedded resource.
127132
For ``POST``, validate the creation payload with
128133
:attr:`~scim2_models.Context.RESOURCE_CREATION_REQUEST`, persist the record, then serialize
129134
with :attr:`~scim2_models.Context.RESOURCE_CREATION_RESPONSE`.

doc/guides/flask.rst

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,11 @@ any other collection.
7979
GET /Users/<id>
8080
^^^^^^^^^^^^^^^
8181

82-
Convert the native record to a SCIM resource with your mapping helper, then serialize with
83-
:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`.
82+
Parse query parameters with :class:`~scim2_models.ResponseParameters`, convert the native
83+
record to a SCIM resource with your mapping helper, then serialize with
84+
:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`, forwarding
85+
``req.attributes`` and ``req.excluded_attributes`` so the response only includes the
86+
requested fields.
8487

8588
.. literalinclude:: _examples/flask_example.py
8689
:language: python
@@ -116,9 +119,12 @@ convert back to native and persist, then serialize the result with
116119
GET /Users
117120
^^^^^^^^^^
118121

119-
Parse pagination parameters with :class:`~scim2_models.SearchRequest`, slice the store
120-
accordingly, then wrap the page in a :class:`~scim2_models.ListResponse` serialized with
121-
:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`.
122+
Parse pagination and filtering parameters with :class:`~scim2_models.SearchRequest`, slice
123+
the store accordingly, then wrap the page in a :class:`~scim2_models.ListResponse` serialized
124+
with :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`.
125+
Pass ``req.attributes`` and ``req.excluded_attributes`` to
126+
:meth:`~scim2_models.ListResponse.model_dump_json` so that the ``attributes`` and
127+
``excludedAttributes`` query parameters are applied to each embedded resource.
122128

123129
.. literalinclude:: _examples/flask_example.py
124130
:language: python

scim2_models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from .messages.message import Message
2828
from .messages.patch_op import PatchOp
2929
from .messages.patch_op import PatchOperation
30+
from .messages.response_parameters import ResponseParameters
3031
from .messages.search_request import SearchRequest
3132
from .path import URN
3233
from .path import Path
@@ -121,6 +122,7 @@
121122
"Required",
122123
"Resource",
123124
"ResourceType",
125+
"ResponseParameters",
124126
"Returned",
125127
"Role",
126128
"SCIMException",

scim2_models/base.py

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,22 +29,71 @@
2929
from scim2_models.utils import _to_camel
3030

3131

32-
def _is_attribute_requested(requested_urns: list[str], current_urn: str) -> bool:
33-
"""Check if an attribute should be included based on the requested URNs.
32+
def _short_attr_path(urn: str) -> str:
33+
"""Extract the short attribute path from a full URN.
34+
35+
For URNs like ``urn:...:User:userName``, returns ``userName``.
36+
For URNs like ``urn:...:User:name.familyName``, returns ``name.familyName``.
37+
For short names like ``userName``, returns ``userName`` as-is.
38+
"""
39+
if ":" in urn:
40+
return urn.rsplit(":", 1)[1]
41+
return urn
42+
43+
44+
def _attr_matches(requested: str, current_urn: str) -> bool:
45+
"""Check if a single requested attribute matches the current field URN.
46+
47+
Supports short names (``userName``), dotted paths (``name.familyName``),
48+
and full extension URNs. Handles parent/child relationships.
49+
"""
50+
req_lower = requested.lower()
51+
52+
if ":" in requested:
53+
current_lower = current_urn.lower()
54+
return (
55+
current_lower == req_lower
56+
or req_lower.startswith(current_lower + ":")
57+
or req_lower.startswith(current_lower + ".")
58+
or current_lower.startswith(req_lower + ".")
59+
or current_lower.startswith(req_lower + ":")
60+
)
61+
62+
current_short = _short_attr_path(current_urn).lower()
63+
return (
64+
current_short == req_lower
65+
or current_short.startswith(req_lower + ".")
66+
or req_lower.startswith(current_short + ".")
67+
)
68+
69+
70+
def _exact_attr_match(attrs: list[str], current_urn: str) -> bool:
71+
"""Check if current_urn exactly matches any entry in attrs (case-insensitive).
72+
73+
Used for ``excludedAttributes`` matching and :attr:`Returned.request` checking,
74+
where parent/child relationship should not apply.
75+
"""
76+
current_short = _short_attr_path(current_urn).lower()
77+
for attr in attrs:
78+
attr_lower = attr.lower()
79+
if ":" in attr:
80+
if current_urn.lower() == attr_lower:
81+
return True
82+
else:
83+
if current_short == attr_lower:
84+
return True
85+
return False
86+
87+
88+
def _is_attribute_requested(requested_attrs: list[str], current_urn: str) -> bool:
89+
"""Check if an attribute should be included based on the requested attributes.
3490
3591
Returns True if:
3692
- The current attribute is explicitly requested
3793
- A sub-attribute of the current attribute is requested
3894
- The current attribute is a sub-attribute of a requested attribute
3995
"""
40-
return (
41-
current_urn in requested_urns
42-
or any(
43-
item.startswith(f"{current_urn}.") or item.startswith(f"{current_urn}:")
44-
for item in requested_urns
45-
)
46-
or any(current_urn.startswith(f"{item}.") for item in requested_urns)
47-
)
96+
return any(_attr_matches(req, current_urn) for req in requested_attrs)
4897

4998

5099
class BaseModel(PydanticBaseModel):
@@ -459,7 +508,11 @@ def _set_complex_attribute_urns(self) -> None:
459508
if not attr_type or not is_complex_attribute(attr_type):
460509
continue
461510

462-
schema = f"{main_schema}{separator}{field_name}"
511+
alias = (
512+
self.__class__.model_fields[field_name].serialization_alias
513+
or field_name
514+
)
515+
schema = f"{main_schema}{separator}{alias}"
463516

464517
if attr_value := getattr(self, field_name):
465518
if isinstance(attr_value, list):
@@ -517,28 +570,26 @@ def _scim_response_serializer(
517570
"""Serialize the fields according to returnability indications passed in the serialization context."""
518571
returnability = self.get_field_annotation(info.field_name, Returned)
519572
attribute_urn = self.get_attribute_urn(info.field_name)
520-
included_urns = info.context.get("scim_attributes", []) if info.context else []
521-
excluded_urns = (
573+
included_attrs = info.context.get("scim_attributes", []) if info.context else []
574+
excluded_attrs = (
522575
info.context.get("scim_excluded_attributes", []) if info.context else []
523576
)
524577

525-
attribute_urn = _normalize_attribute_name(attribute_urn)
526-
included_urns = [_normalize_attribute_name(urn) for urn in included_urns]
527-
excluded_urns = [_normalize_attribute_name(urn) for urn in excluded_urns]
528-
529578
if returnability == Returned.never:
530579
return None
531580

532581
if returnability == Returned.default and (
533582
(
534-
included_urns
535-
and not _is_attribute_requested(included_urns, attribute_urn)
583+
included_attrs
584+
and not _is_attribute_requested(included_attrs, attribute_urn)
536585
)
537-
or attribute_urn in excluded_urns
586+
or _exact_attr_match(excluded_attrs, attribute_urn)
538587
):
539588
return None
540589

541-
if returnability == Returned.request and attribute_urn not in included_urns:
590+
if returnability == Returned.request and not _exact_attr_match(
591+
included_attrs, attribute_urn
592+
):
542593
return None
543594

544595
return value

scim2_models/messages/message.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collections.abc import Callable
2+
from typing import TYPE_CHECKING
23
from typing import Annotated
34
from typing import Any
45
from typing import Union
@@ -15,10 +16,19 @@
1516
from ..scim_object import ScimObject
1617
from ..utils import UNION_TYPES
1718

19+
if TYPE_CHECKING:
20+
from pydantic import FieldSerializationInfo
21+
1822

1923
class Message(ScimObject):
2024
"""SCIM protocol messages as defined by :rfc:`RFC7644 §3.1 <7644#section-3.1>`."""
2125

26+
def _scim_response_serializer(
27+
self, value: Any, info: "FieldSerializationInfo"
28+
) -> Any:
29+
"""Message fields are not subject to attribute filtering."""
30+
return value
31+
2232

2333
def _create_schema_discriminator(
2434
resource_types_schemas: list[str],
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from typing import Any
2+
3+
from pydantic import field_validator
4+
from pydantic import model_validator
5+
6+
from ..base import BaseModel
7+
from ..path import Path
8+
9+
10+
class ResponseParameters(BaseModel):
11+
""":rfc:`RFC7644 §3.9 <7644#section-3.9>` ``attributes`` and ``excludedAttributes`` query parameters."""
12+
13+
attributes: list[Path[Any]] | None = None
14+
"""A multi-valued list of strings indicating the names of resource
15+
attributes to return in the response, overriding the set of attributes that
16+
would be returned by default."""
17+
18+
excluded_attributes: list[Path[Any]] | None = None
19+
"""A multi-valued list of strings indicating the names of resource
20+
attributes to be removed from the default set of attributes to return."""
21+
22+
@field_validator("attributes", "excluded_attributes", mode="before")
23+
@classmethod
24+
def split_comma_separated(cls, value: Any) -> Any:
25+
"""Split comma-separated strings into lists.
26+
27+
:rfc:`RFC7644 §3.9 <7644#section-3.9>` defines these as
28+
comma-separated query parameter values.
29+
"""
30+
if isinstance(value, str):
31+
return [v.strip() for v in value.split(",") if v.strip()]
32+
return value
33+
34+
@model_validator(mode="after")
35+
def attributes_validator(self) -> "ResponseParameters":
36+
if self.attributes and self.excluded_attributes:
37+
raise ValueError(
38+
"'attributes' and 'excluded_attributes' are mutually exclusive"
39+
)
40+
41+
return self

0 commit comments

Comments
 (0)