Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -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
--------------------

Expand Down
21 changes: 18 additions & 3 deletions doc/guides/_examples/django_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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]
Expand All @@ -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):
Expand Down
16 changes: 13 additions & 3 deletions doc/guides/_examples/flask_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -84,9 +85,14 @@ def handle_value_error(error):
@bp.get("/Users/<user:app_record>")
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 --
Expand Down Expand Up @@ -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]
Expand All @@ -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 --
Expand Down
13 changes: 9 additions & 4 deletions doc/guides/django.rst
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,9 @@ Single resource
^^^^^^^^^^^^^^^

``UserView`` handles ``GET``, ``PATCH`` and ``DELETE`` on ``/Users/<id>``.
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),
Expand All @@ -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`.
Expand Down
16 changes: 11 additions & 5 deletions doc/guides/flask.rst
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,11 @@ any other collection.
GET /Users/<id>
^^^^^^^^^^^^^^^

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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions scim2_models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -121,6 +122,7 @@
"Required",
"Resource",
"ResourceType",
"ResponseParameters",
"Returned",
"Role",
"SCIMException",
Expand Down
93 changes: 72 additions & 21 deletions scim2_models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions scim2_models/messages/message.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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],
Expand Down
41 changes: 41 additions & 0 deletions scim2_models/messages/response_parameters.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading