Skip to content

Commit 1729fd3

Browse files
committed
feat: attribute inclusion/exclusions overhaul
- 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 1729fd3

File tree

13 files changed

+398
-39
lines changed

13 files changed

+398
-39
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/messages/list_response.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,20 @@
22
from typing import Generic
33

44
from pydantic import Field
5+
from pydantic import FieldSerializationInfo
6+
from pydantic import SerializerFunctionWrapHandler
57
from pydantic import ValidationInfo
68
from pydantic import ValidatorFunctionWrapHandler
9+
from pydantic import field_serializer
710
from pydantic import model_validator
811
from pydantic_core import PydanticCustomError
912
from typing_extensions import Self
1013

1114
from ..context import Context
1215
from ..path import URN
16+
from ..path import Path
1317
from ..resources.resource import AnyResource
18+
from ..scim_object import ScimObject
1419
from .message import Message
1520
from .message import _GenericMessageMetaclass
1621

@@ -67,3 +72,96 @@ def check_results_number(
6772
)
6873

6974
return obj
75+
76+
@field_serializer("resources", mode="wrap")
77+
def _serialize_resources(
78+
self,
79+
value: list[AnyResource] | None,
80+
handler: SerializerFunctionWrapHandler,
81+
info: FieldSerializationInfo,
82+
) -> Any:
83+
"""Apply attributes/excluded_attributes filtering to each embedded resource."""
84+
if not value:
85+
return handler(value)
86+
87+
attributes = info.context.get("scim_list_attributes") if info.context else None
88+
excluded_attributes = (
89+
info.context.get("scim_list_excluded_attributes") if info.context else None
90+
)
91+
92+
if not attributes and not excluded_attributes:
93+
return handler(value)
94+
95+
scim_ctx = info.context.get("scim") if info.context else None
96+
return [
97+
resource.model_dump(
98+
scim_ctx=scim_ctx,
99+
attributes=attributes or None,
100+
excluded_attributes=excluded_attributes or None,
101+
)
102+
for resource in value
103+
]
104+
105+
def _prepare_model_dump(
106+
self,
107+
scim_ctx: Context | None = Context.DEFAULT,
108+
attributes: list[str | Path[Any]] | None = None,
109+
excluded_attributes: list[str | Path[Any]] | None = None,
110+
**kwargs: Any,
111+
) -> dict[str, Any]:
112+
kwargs = super()._prepare_model_dump(scim_ctx, **kwargs)
113+
if attributes:
114+
kwargs["context"]["scim_list_attributes"] = attributes
115+
if excluded_attributes:
116+
kwargs["context"]["scim_list_excluded_attributes"] = excluded_attributes
117+
return kwargs
118+
119+
def model_dump(
120+
self,
121+
*args: Any,
122+
scim_ctx: Context | None = Context.DEFAULT,
123+
attributes: list[str | Path[Any]] | None = None,
124+
excluded_attributes: list[str | Path[Any]] | None = None,
125+
**kwargs: Any,
126+
) -> dict[str, Any]:
127+
"""Create a model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump`.
128+
129+
:param scim_ctx: If a SCIM context is passed, some default values of
130+
Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM
131+
messages. Pass :data:`None` to get the default Pydantic behavior.
132+
:param attributes: A multi-valued list of strings indicating the names of resource
133+
attributes to return in the response, overriding the set of attributes that
134+
would be returned by default. Invalid values are ignored.
135+
:param excluded_attributes: A multi-valued list of strings indicating the names of resource
136+
attributes to be removed from the default set of attributes to return. Invalid values are ignored.
137+
"""
138+
dump_kwargs = self._prepare_model_dump(
139+
scim_ctx, attributes, excluded_attributes, **kwargs
140+
)
141+
if scim_ctx:
142+
dump_kwargs.setdefault("mode", "json")
143+
return super(ScimObject, self).model_dump(*args, **dump_kwargs)
144+
145+
def model_dump_json(
146+
self,
147+
*args: Any,
148+
scim_ctx: Context | None = Context.DEFAULT,
149+
attributes: list[str | Path[Any]] | None = None,
150+
excluded_attributes: list[str | Path[Any]] | None = None,
151+
**kwargs: Any,
152+
) -> str:
153+
"""Create a JSON model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump_json`.
154+
155+
:param scim_ctx: If a SCIM context is passed, some default values of
156+
Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM
157+
messages. Pass :data:`None` to get the default Pydantic behavior.
158+
:param attributes: A multi-valued list of strings indicating the names of resource
159+
attributes to return in the response, overriding the set of attributes that
160+
would be returned by default. Invalid values are ignored.
161+
:param excluded_attributes: A multi-valued list of strings indicating the names of resource
162+
attributes to be removed from the default set of attributes to return. Invalid values are ignored.
163+
"""
164+
dump_kwargs = self._prepare_model_dump(
165+
scim_ctx, attributes, excluded_attributes, **kwargs
166+
)
167+
return super(ScimObject, self).model_dump_json(*args, **dump_kwargs)
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

scim2_models/messages/search_request.py

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,18 @@
22
from typing import Any
33

44
from pydantic import field_validator
5-
from pydantic import model_validator
65

76
from ..path import URN
87
from ..path import Path
98
from .message import Message
9+
from .response_parameters import ResponseParameters
1010

1111

12-
class SearchRequest(Message):
13-
"""SearchRequest object defined at RFC7644.
14-
15-
https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.3
16-
"""
12+
class SearchRequest(Message, ResponseParameters):
13+
"""SearchRequest object defined at :rfc:`RFC7644 §3.4.3 <7644#section-3.4.3>`."""
1714

1815
__schema__ = URN("urn:ietf:params:scim:api:messages:2.0:SearchRequest")
1916

20-
attributes: list[Path[Any]] | None = None
21-
"""A multi-valued list of strings indicating the names of resource
22-
attributes to return in the response, overriding the set of attributes that
23-
would be returned by default."""
24-
25-
excluded_attributes: list[Path[Any]] | None = None
26-
"""A multi-valued list of strings indicating the names of resource
27-
attributes to be removed from the default set of attributes to return."""
28-
2917
filter: str | None = None
3018
"""The filter string used to request a subset of resources."""
3119

@@ -66,15 +54,6 @@ def count_floor(cls, value: int | None) -> int | None:
6654
"""
6755
return None if value is None else max(0, value)
6856

69-
@model_validator(mode="after")
70-
def attributes_validator(self) -> "SearchRequest":
71-
if self.attributes and self.excluded_attributes:
72-
raise ValueError(
73-
"'attributes' and 'excluded_attributes' are mutually exclusive"
74-
)
75-
76-
return self
77-
7857
@property
7958
def start_index_0(self) -> int | None:
8059
"""The 0 indexed start index."""

0 commit comments

Comments
 (0)