From 95a353a6f8b62907355910bc1cb1b4da6e8202ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Wed, 1 Apr 2026 17:28:55 +0200 Subject: [PATCH] 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 --- doc/changelog.rst | 9 ++ doc/guides/_examples/django_example.py | 21 +++- doc/guides/_examples/flask_example.py | 16 ++- doc/guides/django.rst | 13 ++- doc/guides/flask.rst | 16 ++- scim2_models/__init__.py | 2 + scim2_models/base.py | 93 +++++++++++---- scim2_models/messages/message.py | 10 ++ scim2_models/messages/response_parameters.py | 41 +++++++ scim2_models/messages/search_request.py | 27 +---- scim2_models/resources/resource.py | 73 ------------ scim2_models/scim_object.py | 38 ++++++- tests/test_doc_examples.py | 26 +++++ tests/test_list_response.py | 112 +++++++++++++++++++ tests/test_model_attributes.py | 30 +++++ tests/test_model_serialization.py | 7 +- tests/test_search_request.py | 21 ++++ 17 files changed, 415 insertions(+), 140 deletions(-) create mode 100644 scim2_models/messages/response_parameters.py diff --git a/doc/changelog.rst b/doc/changelog.rst index 5d7c981..a71031f 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,15 @@ Changelog ========= +[0.7.0] - Unreleased +-------------------- + +Added +^^^^^ +- :class:`~scim2_models.ListResponse` ``model_dump`` and ``model_dump_json`` now accept ``attributes`` and ``excluded_attributes`` parameters. :issue:`59` +- 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. +- :class:`~scim2_models.ResponseParameters` and :class:`~scim2_models.SearchRequest` accept comma-separated strings for ``attributes`` and ``excludedAttributes``. + [0.6.6] - 2026-03-12 -------------------- diff --git a/doc/guides/_examples/django_example.py b/doc/guides/_examples/django_example.py index cbe8354..8ed4b51 100644 --- a/doc/guides/_examples/django_example.py +++ b/doc/guides/_examples/django_example.py @@ -13,6 +13,7 @@ from scim2_models import Error from scim2_models import ListResponse from scim2_models import PatchOp +from scim2_models import ResponseParameters from scim2_models import SearchRequest from scim2_models import UniquenessException from scim2_models import User @@ -86,9 +87,18 @@ class UserView(View): """Handle GET, PATCH and DELETE on one SCIM user resource.""" def get(self, request, app_record): + try: + req = ResponseParameters.model_validate(request.GET.dict()) + except ValidationError as error: + return scim_validation_error(error) + scim_user = to_scim_user(app_record) return scim_response( - scim_user.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + scim_user.model_dump_json( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + attributes=req.attributes, + excluded_attributes=req.excluded_attributes, + ) ) def delete(self, request, app_record): @@ -126,9 +136,10 @@ class UsersView(View): def get(self, request): try: - req = SearchRequest.model_validate(request.GET) + req = SearchRequest.model_validate(request.GET.dict()) except ValidationError as error: return scim_validation_error(error) + all_records = list_records() page = all_records[req.start_index_0 : req.stop_index_0] resources = [to_scim_user(record) for record in page] @@ -139,7 +150,11 @@ def get(self, request): resources=resources, ) return scim_response( - response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + response.model_dump_json( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + attributes=req.attributes, + excluded_attributes=req.excluded_attributes, + ) ) def post(self, request): diff --git a/doc/guides/_examples/flask_example.py b/doc/guides/_examples/flask_example.py index 4ee6c24..dd60d13 100644 --- a/doc/guides/_examples/flask_example.py +++ b/doc/guides/_examples/flask_example.py @@ -10,6 +10,7 @@ from scim2_models import Error from scim2_models import ListResponse from scim2_models import PatchOp +from scim2_models import ResponseParameters from scim2_models import SearchRequest from scim2_models import UniquenessException from scim2_models import User @@ -84,9 +85,14 @@ def handle_value_error(error): @bp.get("/Users/") def get_user(app_record): """Return one SCIM user.""" + req = ResponseParameters.model_validate(request.args.to_dict()) scim_user = to_scim_user(app_record) return ( - scim_user.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE), + scim_user.model_dump_json( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + attributes=req.attributes, + excluded_attributes=req.excluded_attributes, + ), HTTPStatus.OK, ) # -- get-user-end -- @@ -128,7 +134,7 @@ def delete_user(app_record): @bp.get("/Users") def list_users(): """Return one page of users as a SCIM ListResponse.""" - req = SearchRequest.model_validate(request.args) + req = SearchRequest.model_validate(request.args.to_dict()) all_records = list_records() page = all_records[req.start_index_0 : req.stop_index_0] resources = [to_scim_user(record) for record in page] @@ -139,7 +145,11 @@ def list_users(): resources=resources, ) return ( - response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE), + response.model_dump_json( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + attributes=req.attributes, + excluded_attributes=req.excluded_attributes, + ), HTTPStatus.OK, ) # -- list-users-end -- diff --git a/doc/guides/django.rst b/doc/guides/django.rst index 7a65729..8d603ea 100644 --- a/doc/guides/django.rst +++ b/doc/guides/django.rst @@ -104,8 +104,9 @@ Single resource ^^^^^^^^^^^^^^^ ``UserView`` handles ``GET``, ``PATCH`` and ``DELETE`` on ``/Users/``. -For ``GET``, convert the native record to a SCIM resource and serialize with -:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. +For ``GET``, parse query parameters with :class:`~scim2_models.ResponseParameters` to honour the +``attributes`` and ``excludedAttributes`` query parameters, convert the native record to a +SCIM resource, and serialize with :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. For ``DELETE``, remove the record and return an empty 204 response. For ``PATCH``, validate the payload with :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`, apply it with :meth:`~scim2_models.PatchOp.patch` (generic, works with any resource type), @@ -121,9 +122,13 @@ Collection ^^^^^^^^^^ ``UsersView`` handles ``GET /Users`` and ``POST /Users``. -For ``GET``, parse pagination parameters with :class:`~scim2_models.SearchRequest`, slice -the store, then wrap the page in a :class:`~scim2_models.ListResponse` serialized with +For ``GET``, parse pagination and filtering parameters with +:class:`~scim2_models.SearchRequest`, slice the store, then wrap the page in a +:class:`~scim2_models.ListResponse` serialized with :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. +``req.attributes`` and ``req.excluded_attributes`` are passed to +:meth:`~scim2_models.ListResponse.model_dump_json` to apply the ``attributes`` and +``excludedAttributes`` query parameters to each embedded resource. For ``POST``, validate the creation payload with :attr:`~scim2_models.Context.RESOURCE_CREATION_REQUEST`, persist the record, then serialize with :attr:`~scim2_models.Context.RESOURCE_CREATION_RESPONSE`. diff --git a/doc/guides/flask.rst b/doc/guides/flask.rst index c95474c..729ef27 100644 --- a/doc/guides/flask.rst +++ b/doc/guides/flask.rst @@ -79,8 +79,11 @@ any other collection. GET /Users/ ^^^^^^^^^^^^^^^ -Convert the native record to a SCIM resource with your mapping helper, then serialize with -:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. +Parse query parameters with :class:`~scim2_models.ResponseParameters`, convert the native +record to a SCIM resource with your mapping helper, then serialize with +:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`, forwarding +``req.attributes`` and ``req.excluded_attributes`` so the response only includes the +requested fields. .. literalinclude:: _examples/flask_example.py :language: python @@ -116,9 +119,12 @@ convert back to native and persist, then serialize the result with GET /Users ^^^^^^^^^^ -Parse pagination parameters with :class:`~scim2_models.SearchRequest`, slice the store -accordingly, then wrap the page in a :class:`~scim2_models.ListResponse` serialized with -:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. +Parse pagination and filtering parameters with :class:`~scim2_models.SearchRequest`, slice +the store accordingly, then wrap the page in a :class:`~scim2_models.ListResponse` serialized +with :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. +Pass ``req.attributes`` and ``req.excluded_attributes`` to +:meth:`~scim2_models.ListResponse.model_dump_json` so that the ``attributes`` and +``excludedAttributes`` query parameters are applied to each embedded resource. .. literalinclude:: _examples/flask_example.py :language: python diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index 7923afd..bbec384 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -27,6 +27,7 @@ from .messages.message import Message from .messages.patch_op import PatchOp from .messages.patch_op import PatchOperation +from .messages.response_parameters import ResponseParameters from .messages.search_request import SearchRequest from .path import URN from .path import Path @@ -121,6 +122,7 @@ "Required", "Resource", "ResourceType", + "ResponseParameters", "Returned", "Role", "SCIMException", diff --git a/scim2_models/base.py b/scim2_models/base.py index c6a0fa8..1e286eb 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -29,22 +29,71 @@ from scim2_models.utils import _to_camel -def _is_attribute_requested(requested_urns: list[str], current_urn: str) -> bool: - """Check if an attribute should be included based on the requested URNs. +def _short_attr_path(urn: str) -> str: + """Extract the short attribute path from a full URN. + + For URNs like ``urn:...:User:userName``, returns ``userName``. + For URNs like ``urn:...:User:name.familyName``, returns ``name.familyName``. + For short names like ``userName``, returns ``userName`` as-is. + """ + if ":" in urn: + return urn.rsplit(":", 1)[1] + return urn + + +def _attr_matches(requested: str, current_urn: str) -> bool: + """Check if a single requested attribute matches the current field URN. + + Supports short names (``userName``), dotted paths (``name.familyName``), + and full extension URNs. Handles parent/child relationships. + """ + req_lower = requested.lower() + + if ":" in requested: + current_lower = current_urn.lower() + return ( + current_lower == req_lower + or req_lower.startswith(current_lower + ":") + or req_lower.startswith(current_lower + ".") + or current_lower.startswith(req_lower + ".") + or current_lower.startswith(req_lower + ":") + ) + + current_short = _short_attr_path(current_urn).lower() + return ( + current_short == req_lower + or current_short.startswith(req_lower + ".") + or req_lower.startswith(current_short + ".") + ) + + +def _exact_attr_match(attrs: list[str], current_urn: str) -> bool: + """Check if current_urn exactly matches any entry in attrs (case-insensitive). + + Used for ``excludedAttributes`` matching and :attr:`Returned.request` checking, + where parent/child relationship should not apply. + """ + current_short = _short_attr_path(current_urn).lower() + for attr in attrs: + attr_lower = attr.lower() + if ":" in attr: + if current_urn.lower() == attr_lower: + return True + else: + if current_short == attr_lower: + return True + return False + + +def _is_attribute_requested(requested_attrs: list[str], current_urn: str) -> bool: + """Check if an attribute should be included based on the requested attributes. Returns True if: - The current attribute is explicitly requested - A sub-attribute of the current attribute is requested - The current attribute is a sub-attribute of a requested attribute """ - return ( - current_urn in requested_urns - or any( - item.startswith(f"{current_urn}.") or item.startswith(f"{current_urn}:") - for item in requested_urns - ) - or any(current_urn.startswith(f"{item}.") for item in requested_urns) - ) + return any(_attr_matches(req, current_urn) for req in requested_attrs) class BaseModel(PydanticBaseModel): @@ -459,7 +508,11 @@ def _set_complex_attribute_urns(self) -> None: if not attr_type or not is_complex_attribute(attr_type): continue - schema = f"{main_schema}{separator}{field_name}" + alias = ( + self.__class__.model_fields[field_name].serialization_alias + or field_name + ) + schema = f"{main_schema}{separator}{alias}" if attr_value := getattr(self, field_name): if isinstance(attr_value, list): @@ -517,28 +570,26 @@ def _scim_response_serializer( """Serialize the fields according to returnability indications passed in the serialization context.""" returnability = self.get_field_annotation(info.field_name, Returned) attribute_urn = self.get_attribute_urn(info.field_name) - included_urns = info.context.get("scim_attributes", []) if info.context else [] - excluded_urns = ( + included_attrs = info.context.get("scim_attributes", []) if info.context else [] + excluded_attrs = ( info.context.get("scim_excluded_attributes", []) if info.context else [] ) - attribute_urn = _normalize_attribute_name(attribute_urn) - included_urns = [_normalize_attribute_name(urn) for urn in included_urns] - excluded_urns = [_normalize_attribute_name(urn) for urn in excluded_urns] - if returnability == Returned.never: return None if returnability == Returned.default and ( ( - included_urns - and not _is_attribute_requested(included_urns, attribute_urn) + included_attrs + and not _is_attribute_requested(included_attrs, attribute_urn) ) - or attribute_urn in excluded_urns + or _exact_attr_match(excluded_attrs, attribute_urn) ): return None - if returnability == Returned.request and attribute_urn not in included_urns: + if returnability == Returned.request and not _exact_attr_match( + included_attrs, attribute_urn + ): return None return value diff --git a/scim2_models/messages/message.py b/scim2_models/messages/message.py index fee05ae..5b005d4 100644 --- a/scim2_models/messages/message.py +++ b/scim2_models/messages/message.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from typing import TYPE_CHECKING from typing import Annotated from typing import Any from typing import Union @@ -15,10 +16,19 @@ from ..scim_object import ScimObject from ..utils import UNION_TYPES +if TYPE_CHECKING: + from pydantic import FieldSerializationInfo + class Message(ScimObject): """SCIM protocol messages as defined by :rfc:`RFC7644 §3.1 <7644#section-3.1>`.""" + def _scim_response_serializer( + self, value: Any, info: "FieldSerializationInfo" + ) -> Any: + """Message fields are not subject to attribute filtering.""" + return value + def _create_schema_discriminator( resource_types_schemas: list[str], diff --git a/scim2_models/messages/response_parameters.py b/scim2_models/messages/response_parameters.py new file mode 100644 index 0000000..73febb1 --- /dev/null +++ b/scim2_models/messages/response_parameters.py @@ -0,0 +1,41 @@ +from typing import Any + +from pydantic import field_validator +from pydantic import model_validator + +from ..base import BaseModel +from ..path import Path + + +class ResponseParameters(BaseModel): + """:rfc:`RFC7644 §3.9 <7644#section-3.9>` ``attributes`` and ``excludedAttributes`` query parameters.""" + + attributes: list[Path[Any]] | None = None + """A multi-valued list of strings indicating the names of resource + attributes to return in the response, overriding the set of attributes that + would be returned by default.""" + + excluded_attributes: list[Path[Any]] | None = None + """A multi-valued list of strings indicating the names of resource + attributes to be removed from the default set of attributes to return.""" + + @field_validator("attributes", "excluded_attributes", mode="before") + @classmethod + def split_comma_separated(cls, value: Any) -> Any: + """Split comma-separated strings into lists. + + :rfc:`RFC7644 §3.9 <7644#section-3.9>` defines these as + comma-separated query parameter values. + """ + if isinstance(value, str): + return [v.strip() for v in value.split(",") if v.strip()] + return value + + @model_validator(mode="after") + def attributes_validator(self) -> "ResponseParameters": + if self.attributes and self.excluded_attributes: + raise ValueError( + "'attributes' and 'excluded_attributes' are mutually exclusive" + ) + + return self diff --git a/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index 3c37347..3c08a15 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -2,30 +2,18 @@ from typing import Any from pydantic import field_validator -from pydantic import model_validator from ..path import URN from ..path import Path from .message import Message +from .response_parameters import ResponseParameters -class SearchRequest(Message): - """SearchRequest object defined at RFC7644. - - https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.3 - """ +class SearchRequest(Message, ResponseParameters): + """SearchRequest object defined at :rfc:`RFC7644 §3.4.3 <7644#section-3.4.3>`.""" __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:SearchRequest") - attributes: list[Path[Any]] | None = None - """A multi-valued list of strings indicating the names of resource - attributes to return in the response, overriding the set of attributes that - would be returned by default.""" - - excluded_attributes: list[Path[Any]] | None = None - """A multi-valued list of strings indicating the names of resource - attributes to be removed from the default set of attributes to return.""" - filter: str | None = None """The filter string used to request a subset of resources.""" @@ -66,15 +54,6 @@ def count_floor(cls, value: int | None) -> int | None: """ return None if value is None else max(0, value) - @model_validator(mode="after") - def attributes_validator(self) -> "SearchRequest": - if self.attributes and self.excluded_attributes: - raise ValueError( - "'attributes' and 'excluded_attributes' are mutually exclusive" - ) - - return self - @property def start_index_0(self) -> int | None: """The 0 indexed start index.""" diff --git a/scim2_models/resources/resource.py b/scim2_models/resources/resource.py index 3e50ccb..a38204b 100644 --- a/scim2_models/resources/resource.py +++ b/scim2_models/resources/resource.py @@ -387,79 +387,6 @@ def from_schema(cls, schema: "Schema") -> type["Resource[Any]"]: return _make_python_model(schema, cls) - def _prepare_model_dump( - self, - scim_ctx: Context | None = Context.DEFAULT, - attributes: list[str | Path[Any]] | None = None, - excluded_attributes: list[str | Path[Any]] | None = None, - **kwargs: Any, - ) -> dict[str, Any]: - kwargs = super()._prepare_model_dump(scim_ctx, **kwargs) - - # RFC 7644: "SHOULD ignore any query parameters they do not recognize" - bound_path = Path.__class_getitem__(type(self)) - kwargs["context"]["scim_attributes"] = [ - urn - for attribute in (attributes or []) - if (urn := bound_path(attribute).urn) is not None - ] - kwargs["context"]["scim_excluded_attributes"] = [ - urn - for attribute in (excluded_attributes or []) - if (urn := bound_path(attribute).urn) is not None - ] - return kwargs - - def model_dump( - self, - *args: Any, - scim_ctx: Context | None = Context.DEFAULT, - attributes: list[str | Path[Any]] | None = None, - excluded_attributes: list[str | Path[Any]] | None = None, - **kwargs: Any, - ) -> dict[str, Any]: - """Create a model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump`. - - :param scim_ctx: If a SCIM context is passed, some default values of - Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM - messages. Pass :data:`None` to get the default Pydantic behavior. - :param attributes: A multi-valued list of strings indicating the names of resource - attributes to return in the response, overriding the set of attributes that - would be returned by default. Invalid values are ignored. - :param excluded_attributes: A multi-valued list of strings indicating the names of resource - attributes to be removed from the default set of attributes to return. Invalid values are ignored. - """ - dump_kwargs = self._prepare_model_dump( - scim_ctx, attributes, excluded_attributes, **kwargs - ) - if scim_ctx: - dump_kwargs.setdefault("mode", "json") - return super(ScimObject, self).model_dump(*args, **dump_kwargs) - - def model_dump_json( - self, - *args: Any, - scim_ctx: Context | None = Context.DEFAULT, - attributes: list[str | Path[Any]] | None = None, - excluded_attributes: list[str | Path[Any]] | None = None, - **kwargs: Any, - ) -> str: - """Create a JSON model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump_json`. - - :param scim_ctx: If a SCIM context is passed, some default values of - Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM - messages. Pass :data:`None` to get the default Pydantic behavior. - :param attributes: A multi-valued list of strings indicating the names of resource - attributes to return in the response, overriding the set of attributes that - would be returned by default. Invalid values are ignored. - :param excluded_attributes: A multi-valued list of strings indicating the names of resource - attributes to be removed from the default set of attributes to return. Invalid values are ignored. - """ - dump_kwargs = self._prepare_model_dump( - scim_ctx, attributes, excluded_attributes, **kwargs - ) - return super(ScimObject, self).model_dump_json(*args, **dump_kwargs) - AnyResource = TypeVar("AnyResource", bound="Resource[Any]") diff --git a/scim2_models/scim_object.py b/scim2_models/scim_object.py index abe014f..b48121e 100644 --- a/scim2_models/scim_object.py +++ b/scim2_models/scim_object.py @@ -17,6 +17,7 @@ from .base import BaseModel from .context import Context from .path import URN +from .path import Path if TYPE_CHECKING: pass @@ -105,6 +106,8 @@ def _validate_schemas_attribute( def _prepare_model_dump( self, scim_ctx: Context | None = Context.DEFAULT, + attributes: list[str | Path[Any]] | None = None, + excluded_attributes: list[str | Path[Any]] | None = None, **kwargs: Any, ) -> dict[str, Any]: kwargs.setdefault("context", {}).setdefault("scim", scim_ctx) @@ -113,12 +116,21 @@ def _prepare_model_dump( kwargs.setdefault("exclude_none", True) kwargs.setdefault("by_alias", True) + if attributes: + kwargs["context"]["scim_attributes"] = [str(a) for a in attributes] + if excluded_attributes: + kwargs["context"]["scim_excluded_attributes"] = [ + str(a) for a in excluded_attributes + ] + return kwargs def model_dump( self, *args: Any, scim_ctx: Context | None = Context.DEFAULT, + attributes: list[str | Path[Any]] | None = None, + excluded_attributes: list[str | Path[Any]] | None = None, **kwargs: Any, ) -> dict[str, Any]: """Create a model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump`. @@ -126,8 +138,18 @@ def model_dump( :param scim_ctx: If a SCIM context is passed, some default values of Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM messages. Pass :data:`None` to get the default Pydantic behavior. + :param attributes: A multi-valued list of strings indicating the names of resource + attributes to return in the response, overriding the set of attributes that + would be returned by default. Invalid values are ignored. + :param excluded_attributes: A multi-valued list of strings indicating the names of resource + attributes to be removed from the default set of attributes to return. Invalid values are ignored. """ - dump_kwargs = self._prepare_model_dump(scim_ctx, **kwargs) + dump_kwargs = self._prepare_model_dump( + scim_ctx, + attributes=attributes, + excluded_attributes=excluded_attributes, + **kwargs, + ) if scim_ctx: dump_kwargs.setdefault("mode", "json") return super(BaseModel, self).model_dump(*args, **dump_kwargs) @@ -136,6 +158,8 @@ def model_dump_json( self, *args: Any, scim_ctx: Context | None = Context.DEFAULT, + attributes: list[str | Path[Any]] | None = None, + excluded_attributes: list[str | Path[Any]] | None = None, **kwargs: Any, ) -> str: """Create a JSON model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump_json`. @@ -143,6 +167,16 @@ def model_dump_json( :param scim_ctx: If a SCIM context is passed, some default values of Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM messages. Pass :data:`None` to get the default Pydantic behavior. + :param attributes: A multi-valued list of strings indicating the names of resource + attributes to return in the response, overriding the set of attributes that + would be returned by default. Invalid values are ignored. + :param excluded_attributes: A multi-valued list of strings indicating the names of resource + attributes to be removed from the default set of attributes to return. Invalid values are ignored. """ - dump_kwargs = self._prepare_model_dump(scim_ctx, **kwargs) + dump_kwargs = self._prepare_model_dump( + scim_ctx, + attributes=attributes, + excluded_attributes=excluded_attributes, + **kwargs, + ) return super(BaseModel, self).model_dump_json(*args, **dump_kwargs) diff --git a/tests/test_doc_examples.py b/tests/test_doc_examples.py index f840aa1..660f01f 100644 --- a/tests/test_doc_examples.py +++ b/tests/test_doc_examples.py @@ -55,6 +55,19 @@ def test_flask_example_smoke(): assert list_response.status_code == 200 assert list_response.get_json()["totalResults"] == 1 + get_attributes_response = client.get( + f"/scim/v2/Users/{user_id}?attributes=userName" + ) + assert get_attributes_response.status_code == 200 + assert "userName" in get_attributes_response.get_json() + assert "displayName" not in get_attributes_response.get_json() + + list_attributes_response = client.get("/scim/v2/Users?attributes=userName") + assert list_attributes_response.status_code == 200 + resources = list_attributes_response.get_json()["Resources"] + assert "userName" in resources[0] + assert "displayName" not in resources[0] + duplicate_response = client.post( "/scim/v2/Users", json={ @@ -128,6 +141,19 @@ def test_django_example_smoke(): assert list_response.status_code == 200 assert json.loads(list_response.content)["totalResults"] == 1 + get_attributes_response = client.get( + f"/scim/v2/Users/{user_id}?attributes=userName" + ) + assert get_attributes_response.status_code == 200 + assert "userName" in json.loads(get_attributes_response.content) + assert "displayName" not in json.loads(get_attributes_response.content) + + list_attributes_response = client.get("/scim/v2/Users?attributes=userName") + assert list_attributes_response.status_code == 200 + resources = json.loads(list_attributes_response.content)["Resources"] + assert "userName" in resources[0] + assert "displayName" not in resources[0] + duplicate_response = client.post( "/scim/v2/Users", data=json.dumps( diff --git a/tests/test_list_response.py b/tests/test_list_response.py index 24cef07..bb71751 100644 --- a/tests/test_list_response.py +++ b/tests/test_list_response.py @@ -215,6 +215,118 @@ def test_list_response_schema_ordering(): ListResponse[User[EnterpriseUser] | Group].model_validate(payload) +def test_attributes_inclusion(): + """ListResponse.model_dump propagates the 'attributes' parameter to embedded resources.""" + response = ListResponse[User]( + total_results=1, + resources=[ + User(id="user-id", user_name="user-name", display_name="display-name") + ], + ) + payload = response.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, attributes=["userName"] + ) + assert payload == { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 1, + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "user-id", + "userName": "user-name", + } + ], + } + + +def test_excluded_attributes(): + """ListResponse.model_dump propagates the 'excluded_attributes' parameter to embedded resources.""" + response = ListResponse[User]( + total_results=1, + resources=[ + User(id="user-id", user_name="user-name", display_name="display-name") + ], + ) + payload = response.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, excluded_attributes=["displayName"] + ) + assert "displayName" not in payload["Resources"][0] + assert payload["Resources"][0]["userName"] == "user-name" + + +def test_attributes_inclusion_with_full_urn(): + """ListResponse propagates full URN attributes to embedded resources.""" + response = ListResponse[User]( + total_results=1, + resources=[ + User(id="user-id", user_name="user-name", display_name="display-name") + ], + ) + payload = response.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + attributes=["urn:ietf:params:scim:schemas:core:2.0:User:userName"], + ) + resource = payload["Resources"][0] + assert resource["userName"] == "user-name" + assert "displayName" not in resource + + +def test_excluded_attributes_with_full_urn(): + """ListResponse propagates full URN excluded attributes to embedded resources.""" + response = ListResponse[User]( + total_results=1, + resources=[ + User(id="user-id", user_name="user-name", display_name="display-name") + ], + ) + payload = response.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + excluded_attributes=["urn:ietf:params:scim:schemas:core:2.0:User:displayName"], + ) + resource = payload["Resources"][0] + assert "displayName" not in resource + assert resource["userName"] == "user-name" + + +def test_attributes_with_union_type(load_sample): + """ListResponse with a Union type resolves attributes against the matching resource type.""" + user_payload = load_sample("rfc7643-8.1-user-minimal.json") + group_payload = load_sample("rfc7643-8.4-group.json") + payload = { + "totalResults": 2, + "itemsPerPage": 10, + "startIndex": 1, + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "Resources": [user_payload, group_payload], + } + response = ListResponse[User | Group].model_validate(payload) + dumped = response.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, attributes=["userName"] + ) + user_resource = dumped["Resources"][0] + assert "userName" in user_resource + assert "displayName" not in user_resource + + +def test_attributes_with_empty_resources(): + """ListResponse serialization handles empty resources when attributes are set.""" + response = ListResponse[User](total_results=0, resources=[]) + payload = response.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, attributes=["userName"] + ) + assert payload["Resources"] == [] + + +def test_model_dump_without_scim_context(): + """ListResponse.model_dump works without a SCIM context.""" + response = ListResponse[User]( + total_results=1, + resources=[User(id="user-id", user_name="user-name")], + ) + payload = response.model_dump(scim_ctx=None) + assert payload["resources"][0]["user_name"] == "user-name" + + def test_total_results_required(): """ListResponse.total_results is required.""" payload = { diff --git a/tests/test_model_attributes.py b/tests/test_model_attributes.py index 91e065f..418b88c 100644 --- a/tests/test_model_attributes.py +++ b/tests/test_model_attributes.py @@ -347,3 +347,33 @@ def test_multivalued_complex_attribute_inclusion_includes_sub_attributes(): {"value": "user-1", "type": "User"}, {"value": "user-2", "type": "User"}, ] + + +def test_extension_excluded_by_full_urn(): + """Excluding an extension attribute with its full URN removes only that attribute.""" + user = User[EnterpriseUser].model_validate( + { + "userName": "foobar", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { + "employeeNumber": "12345", + "department": "Engineering", + }, + } + ) + result = user.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + excluded_attributes=[ + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ], + ) + ext = result["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"] + assert "employeeNumber" not in ext + assert ext["department"] == "Engineering" + + +def test_short_attr_path_with_plain_name(): + """The short-path helper returns plain attribute names unchanged.""" + from scim2_models.base import _short_attr_path + + assert _short_attr_path("userName") == "userName" + assert _short_attr_path("name.familyName") == "name.familyName" diff --git a/tests/test_model_serialization.py b/tests/test_model_serialization.py index 44f6e9e..5f41aa1 100644 --- a/tests/test_model_serialization.py +++ b/tests/test_model_serialization.py @@ -195,16 +195,15 @@ def test_invalid_attributes(): """Test that invalid attributes are ignored per RFC 7644 recommendation.""" resource = SupRetResource(id="id", always_returned="x", default_returned="x") - # Invalid attributes should be ignored, not raise errors + # Invalid attributes should be silently ignored: no match means only + # Returned.always attributes remain. result = resource.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, attributes={"invalidAttribute"} ) - # Should return default response (alwaysReturned attributes) assert result == { "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", - "defaultReturned": "x", } result = resource.model_dump( @@ -215,7 +214,6 @@ def test_invalid_attributes(): "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", - "defaultReturned": "x", } result = resource.model_dump( @@ -226,7 +224,6 @@ def test_invalid_attributes(): "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", - "defaultReturned": "x", } diff --git a/tests/test_search_request.py b/tests/test_search_request.py index af3b717..b3aa76f 100644 --- a/tests/test_search_request.py +++ b/tests/test_search_request.py @@ -185,6 +185,27 @@ def test_search_request_complex_paths_allowed(): assert len(request.attributes) == 3 +def test_comma_separated_attributes(): + """SearchRequest accepts comma-separated strings for attributes.""" + req = SearchRequest.model_validate({"attributes": "userName,displayName"}) + assert req.attributes == ["userName", "displayName"] + + req = SearchRequest.model_validate({"excludedAttributes": "password, phoneNumbers"}) + assert req.excluded_attributes == ["password", "phoneNumbers"] + + +def test_comma_separated_single_attribute(): + """A single attribute value without comma is accepted as-is.""" + req = SearchRequest.model_validate({"attributes": "userName"}) + assert req.attributes == ["userName"] + + +def test_comma_separated_empty_string(): + """An empty string produces an empty list.""" + req = SearchRequest.model_validate({"attributes": ""}) + assert req.attributes == [] + + def test_search_request_empty_lists(): """Test that empty attribute lists are handled correctly.""" valid_data = {