From 13f9cb7ac359ee997ca852a1fc0543d76c51a4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Wed, 24 Jun 2026 15:53:40 +0200 Subject: [PATCH 01/15] feat: implement bulk operations --- doc/changelog.rst | 43 +- doc/integrations/_examples/integrations.py | 8 +- doc/tutorial.rst | 691 +++++++++++++++++++++ scim2_models/messages/bulk.py | 71 ++- scim2_models/resources/resource.py | 12 + tests/test_bulk.py | 297 +++++++++ tests/test_model_validation.py | 17 + 7 files changed, 1096 insertions(+), 43 deletions(-) create mode 100644 doc/tutorial.rst create mode 100644 tests/test_bulk.py diff --git a/doc/changelog.rst b/doc/changelog.rst index f6a9d0ae..4f783e0e 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -11,40 +11,15 @@ Added :issue:`17` - PATCH paths take a value selection, such as ``emails[type eq "work"].value``. - :class:`~scim2_models.SearchRequest` and :class:`~scim2_models.ResponseParameters` take the - resource type an endpoint serves, as in ``SearchRequest[User]``, or ``SearchRequest[User | - Group]`` for an endpoint serving several. Their :attr:`~scim2_models.SearchRequest.filter`, - :attr:`~scim2_models.SearchRequest.sort_by`, :attr:`~scim2_models.ResponseParameters.attributes` - and :attr:`~scim2_models.ResponseParameters.excluded_attributes` resolve against those models, - so a misspelled attribute is caught at validation time. -- :meth:`Path.resolve ` answers the - :class:`~scim2_models.AttributeBinding` a path designates: the model holding the attribute, its - type, its URN and its annotations. -- :meth:`ScimFilter.quote ` renders a value as a filter literal. - On Python 3.14, :class:`~scim2_models.ScimFilter` and :class:`~scim2_models.Path` take a - t-string and quote what is interpolated, so a value cannot be read as syntax. -- :class:`~scim2_models.ScimProvider` describes a SCIM service: the models it serves, and the - :class:`~scim2_models.Schema`, :class:`~scim2_models.ResourceType` and - :class:`~scim2_models.ServiceProviderConfig` objects its discovery endpoints answer. - :meth:`~scim2_models.ScimProvider.from_discovery` builds one from what a service publishes, and - a service that cannot be described is refused with - :class:`~scim2_models.ScimProviderError`. See :doc:`how-to/describe-a-scim-service`. :issue:`108` -- An extension may be declared required, as in - ``User[Annotated[EnterpriseUser, Required.true]]``. A creation or a replacement request that - leaves it out is refused. See :doc:`how-to/define-custom-models`. :issue:`105` -- :class:`~scim2_models.ScimPolicy` states how much a payload may depart from the specification - and still be read. :attr:`~scim2_models.ScimPolicy.unknown` accepts the attributes no model - declares, and :attr:`~scim2_models.ScimPolicy.remove_value_as_filter` accepts the PATCH - ``remove`` `Microsoft Entra ID - `_ - sends. Name a policy at the call, or open a ``with`` block on it - or on a provider carrying one. Every setting defaults to the strict reading, so nothing changes - until one is chosen. See :doc:`how-to/tolerate-a-nonconformant-peer`. :issue:`85` :issue:`108` -- :meth:`~scim2_models.BaseModel.model_dump` and - :meth:`~scim2_models.BaseModel.model_dump_json` take a ``response_parameters``: the - :class:`~scim2_models.ResponseParameters` a client sent, instead of its ``attributes`` and - ``excludedAttributes`` spelled out one by one. A :class:`~scim2_models.SearchRequest` is one, - so a server answering ``POST /.search`` passes the request it received. :issue:`141` -- lark is a new dependency. + resource type an endpoint serves, as in ``SearchRequest[User]``, which resolves + :attr:`~scim2_models.SearchRequest.sort_by`, + :attr:`~scim2_models.ResponseParameters.attributes` and + :attr:`~scim2_models.ResponseParameters.excluded_attributes` against that model. An endpoint + covering several of them takes a union, as in ``SearchRequest[User | Group]``, and so does + :class:`~scim2_models.Path`; a path resolves against the first type declaring it, so + ``sortBy`` answers on a root query too. +- Support for bulk operations. + Changed ^^^^^^^ diff --git a/doc/integrations/_examples/integrations.py b/doc/integrations/_examples/integrations.py index 4b6228b9..8d75f588 100644 --- a/doc/integrations/_examples/integrations.py +++ b/doc/integrations/_examples/integrations.py @@ -11,15 +11,15 @@ from scim2_models import ComplexAttribute from scim2_models import ETag from scim2_models import Filter -from scim2_models import InvalidPathException from scim2_models import Group +from scim2_models import InvalidPathException from scim2_models import Meta -from scim2_models import Path from scim2_models import Patch +from scim2_models import Path from scim2_models import ResourceType from scim2_models import ScimProvider -from scim2_models import ServiceProviderConfig from scim2_models import SearchRequest +from scim2_models import ServiceProviderConfig from scim2_models import Sort from scim2_models import UniquenessException from scim2_models import User @@ -233,7 +233,7 @@ def to_scim_group(record): models=[User], config=ServiceProviderConfig( patch=Patch(supported=True), - bulk=Bulk(supported=False, max_operations=0, max_payload_size=0), + bulk=Bulk(supported=True, max_operations=100, max_payload_size=1048576), filter=Filter(supported=True, max_results=MAX_RESULTS), change_password=ChangePassword(supported=False), sort=Sort(supported=True), diff --git a/doc/tutorial.rst b/doc/tutorial.rst new file mode 100644 index 00000000..2b8e8a31 --- /dev/null +++ b/doc/tutorial.rst @@ -0,0 +1,691 @@ +Tutorial +-------- + +Attribute access +================ + +SCIM resources support two ways to access and modify attributes. +The standard Python dot notation uses snake_case attribute names, while the bracket notation accepts SCIM paths as defined in :rfc:`RFC7644 §3.10 <7644#section-3.10>`. + +.. doctest:: + + >>> from scim2_models import User + + >>> user = User(user_name="bjensen") + >>> user.display_name = "Barbara Jensen" + >>> user["nickName"] = "Babs" + >>> user["name.familyName"] = "Jensen" + +Attributes can be removed with ``del`` or by assigning :data:`None` to the attribute. + +.. doctest:: + + >>> del user["nickName"] + >>> user.nick_name is None + True + +Model parsing +============= + +Use Pydantic's :func:`~scim2_models.BaseModel.model_validate` method to parse and validate SCIM2 payloads. + + +.. code-block:: python + :emphasize-lines: 17 + + >>> from scim2_models import User + >>> import datetime + + >>> payload = { + ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + ... "id": "2819c223-7f76-453a-919d-413861904646", + ... "userName": "bjensen@example.com", + ... "meta": { + ... "resourceType": "User", + ... "created": "2010-01-23T04:56:22Z", + ... "lastModified": "2011-05-13T04:42:34Z", + ... "version": 'W\\/"3694e05e9dff590"', + ... "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", + ... }, + ... } + + >>> user = User.model_validate(payload) + >>> user.user_name + 'bjensen@example.com' + >>> user.meta.created # doctest: +ELLIPSIS + datetime.datetime(2010, 1, 23, 4, 56, 22, tzinfo=...) + +Payloads that have not been decoded yet can be handled by +:func:`~scim2_models.BaseModel.model_validate_json`. +Malformed JSON raises a :class:`~pydantic_core.ValidationError`, like any other invalid payload. + +.. code-block:: python + + >>> import json + + >>> user = User.model_validate_json(json.dumps(payload)) + >>> user.user_name + 'bjensen@example.com' + + +Model serialization +=================== + +Pydantic :func:`~scim2_models.BaseModel.model_dump` method has been tuned to produce valid SCIM2 payloads. + +.. code-block:: python + :emphasize-lines: 16 + + >>> from scim2_models import User, Meta + >>> import datetime + + >>> user = User( + ... id="2819c223-7f76-453a-919d-413861904646", + ... user_name="bjensen@example.com", + ... meta=Meta( + ... resource_type="User", + ... created=datetime.datetime(2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc), + ... last_modified=datetime.datetime(2011, 5, 13, 4, 42, 34, tzinfo=datetime.timezone.utc), + ... version='W\\/"3694e05e9dff590"', + ... location="https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", + ... ), + ... ) + + >>> dump = user.model_dump() + >>> assert dump == { + ... "schemas": [ + ... "urn:ietf:params:scim:schemas:core:2.0:User" + ... ], + ... "id": "2819c223-7f76-453a-919d-413861904646", + ... "meta": { + ... "resourceType": "User", + ... "created": "2010-01-23T04:56:22Z", + ... "lastModified": "2011-05-13T04:42:34Z", + ... "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", + ... "version": "W\\/\"3694e05e9dff590\"" + ... }, + ... "userName": "bjensen@example.com" + ... } + +Contexts +======== + +The SCIM specifications detail some :class:`~scim2_models.Mutability` and :class:`~scim2_models.Returned` parameters for model attributes. +Depending on the context, they will indicate that attributes should be present, absent, or ignored. + +For instance, attributes marked as :attr:`~scim2_models.Mutability.read_only` should not be sent by SCIM clients on resource creation requests. +By passing the right :class:`~scim2_models.Context` to the :meth:`~scim2_models.BaseModel.model_dump` method, only the expected fields will be dumped for this context: + +.. code-block:: python + :caption: Client generating a resource creation request payload + + >>> from scim2_models import User, Context + >>> user = User(user_name="bjensen@example.com") + >>> payload = user.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST) + +In the same fashion, by passing the right :class:`~scim2_models.Context` to the :meth:`~scim2_models.BaseModel.model_validate` method, +fields with unexpected values will raise :class:`~pydantic_core.ValidationError`: + +.. code-block:: python + :caption: Server validating a resource creation request payload + + >>> from scim2_models import User, Context, Error + >>> from pydantic import ValidationError + >>> try: + ... obj = User.model_validate(payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST) + ... except ValidationError: + ... obj = Error(...) + +:meth:`~scim2_models.BaseModel.model_validate_json` takes the same :paramref:`~scim2_models.BaseModel.model_validate_json.scim_ctx` parameter. + +Context annotations +=================== + +Context type aliases +^^^^^^^^^^^^^^^^^^^^ + +scim2-models provides generic type aliases that wrap +:class:`~scim2_models.SCIMValidator` and :class:`~scim2_models.SCIMSerializer` for each +SCIM context. ``*RequestContext`` aliases inject the context during **validation**, +``*ResponseContext`` aliases during **serialization**: + +- :class:`~scim2_models.CreationRequestContext` / :class:`~scim2_models.CreationResponseContext` — resource creation (``POST``) +- :class:`~scim2_models.QueryRequestContext` / :class:`~scim2_models.QueryResponseContext` — resource query (``GET``) +- :class:`~scim2_models.ReplacementRequestContext` / :class:`~scim2_models.ReplacementResponseContext` — resource replacement (``PUT``) +- :class:`~scim2_models.SearchRequestContext` / :class:`~scim2_models.SearchResponseContext` — search (``POST /…/.search``) +- :class:`~scim2_models.PatchRequestContext` / :class:`~scim2_models.PatchResponseContext` — patch (``PATCH``) + +.. code-block:: python + + >>> from pydantic import TypeAdapter + >>> from scim2_models import User, CreationRequestContext, CreationResponseContext + + >>> adapter = TypeAdapter(CreationRequestContext[User]) + >>> user = adapter.validate_python({ + ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + ... "userName": "bjensen", + ... "id": "should-be-stripped", + ... }) + >>> user.id is None + True + + >>> adapter = TypeAdapter(CreationResponseContext[User]) + >>> user.id = "123" + >>> data = adapter.dump_python(user) + >>> "password" not in data + True + +In FastAPI for instance, they can be used directly in endpoint signatures: + +.. code-block:: python + + from scim2_models import CreationRequestContext, CreationResponseContext, User + + @router.post("/Users", status_code=201) + async def create_user( + user: CreationRequestContext[User], + ) -> CreationResponseContext[User]: + ... + +See the :doc:`guides/fastapi` guide for a complete example. + +.. note:: + + ``*ResponseContext`` aliases do not support the ``attributes`` / + ``excludedAttributes`` parameters defined in + :rfc:`RFC 7644 §3.9 <7644#section-3.9>`. When you need to forward those + parameters, use ``model_dump_json`` explicitly instead. + +Low-level markers +^^^^^^^^^^^^^^^^^ + +For more advanced usage, the underlying markers can be used directly with +:data:`typing.Annotated`: + +- :class:`~scim2_models.SCIMValidator` — injects a context during **validation**. +- :class:`~scim2_models.SCIMSerializer` — injects a context during **serialization**. + +.. code-block:: python + + >>> from typing import Annotated + >>> from pydantic import TypeAdapter + >>> from scim2_models import User, Context, SCIMSerializer + + >>> adapter = TypeAdapter( + ... Annotated[User, SCIMSerializer(Context.RESOURCE_QUERY_RESPONSE)] + ... ) + >>> user = User(user_name="bjensen", password="secret") + >>> user.id = "123" + >>> data = adapter.dump_python(user) + >>> "password" not in data + True + +Attributes inclusions and exclusions +==================================== + +In some situations it might be needed to exclude, or only include a given set of attributes when serializing a model. +This happens for instance when servers build response payloads for clients requesting only a subset of the model attributes. +As defined in :rfc:`RFC7644 §3.9 <7644#section-3.9>`, :code:`attributes` and :code:`excluded_attributes` parameters can +be passed to :meth:`~scim2_models.BaseModel.model_dump`. +The expected attribute notation is the one detailed on :rfc:`RFC7644 §3.10 <7644#section-3.10>`, +like :code:`urn:ietf:params:scim:schemas:core:2.0:User:userName`, or :code:`userName` for short. + +.. code-block:: python + :emphasize-lines: 5 + + >>> from scim2_models import User, Context + >>> user = User(user_name="bjensen@example.com", display_name="bjensen") + >>> payload = user.model_dump( + ... scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ... excluded_attributes=["displayName"] + ... ) + >>> assert payload == { + ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + ... "userName": "bjensen@example.com", + ... } + +Values read from :attr:`~scim2_models.ResponseParameters.attributes` and :attr:`~scim2_models.ResponseParameters.excluded_attributes` in :class:`~scim2_models.SearchRequest` objects can directly be used in :meth:`~scim2_models.BaseModel.model_dump`. + +Attributes inclusions and exclusions interact with attributes :class:`~scim2_models.Returned`, in the server response :class:`Contexts `: + +- attributes annotated with :attr:`~scim2_models.Returned.always` will always be dumped; +- attributes annotated with :attr:`~scim2_models.Returned.never` will never be dumped; +- attributes annotated with :attr:`~scim2_models.Returned.default` will be dumped unless being explicitly excluded; +- attributes annotated with :attr:`~scim2_models.Returned.request` will not be dumped unless being explicitly included. + +Typed ListResponse +================== + +:class:`~scim2_models.ListResponse` models take a type, or a union of types. +You must pass the type you expect in the response, e.g. +:class:`~scim2_models.ListResponse`\ [:class:`~scim2_models.User`] or +:class:`~scim2_models.ListResponse`\ [:class:`~scim2_models.User` | :class:`~scim2_models.Group`]. +If a response resource type cannot be found, a ``pydantic.ValidationError`` will be raised. + +.. code-block:: python + :emphasize-lines: 48 + + >>> from scim2_models import User, Group, ListResponse + + >>> payload = { + ... "totalResults": 2, + ... "itemsPerPage": 10, + ... "startIndex": 1, + ... "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + ... "Resources": [ + ... { + ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + ... "id": "2819c223-7f76-453a-919d-413861904646", + ... "userName": "bjensen@example.com", + ... "meta": { + ... "resourceType": "User", + ... "created": "2010-01-23T04:56:22Z", + ... "lastModified": "2011-05-13T04:42:34Z", + ... "version": 'W\\/"3694e05e9dff590"', + ... "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", + ... }, + ... }, + ... { + ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + ... "id": "e9e30dba-f08f-4109-8486-d5c6a331660a", + ... "displayName": "Tour Guides", + ... "members": [ + ... { + ... "value": "2819c223-7f76-453a-919d-413861904646", + ... "$ref": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", + ... "display": "Babs Jensen", + ... }, + ... { + ... "value": "902c246b-6245-4190-8e05-00816be7344a", + ... "$ref": "https://example.com/v2/Users/902c246b-6245-4190-8e05-00816be7344a", + ... "display": "Mandy Pepperidge", + ... }, + ... ], + ... "meta": { + ... "resourceType": "Group", + ... "created": "2010-01-23T04:56:22Z", + ... "lastModified": "2011-05-13T04:42:34Z", + ... "version": 'W\\/"3694e05e9dff592"', + ... "location": "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a", + ... }, + ... }, + ... ], + ... } + + >>> response = ListResponse[User | Group].model_validate(payload) + >>> user, group = response.resources + >>> type(user) + + >>> type(group) + + + +Schema extensions +================= + +:rfc:`RFC7643 §3.3 <7643#section-3.3>` extensions are supported. +Any class inheriting from :class:`~scim2_models.Extension` can be passed as a :class:`~scim2_models.Resource` type parameter, e.g. ``user = User[EnterpriseUser]`` or ``user = User[EnterpriseUser | SuperHero]``. +Extensions attributes are accessed with brackets, e.g. ``user[EnterpriseUser].employee_number``, where ``user[EnterpriseUser]`` is a shortcut for ``user["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"]``. + +.. code-block:: python + + >>> import datetime + >>> from scim2_models import User, EnterpriseUser, Meta + + >>> user = User[EnterpriseUser]( + ... id="2819c223-7f76-453a-919d-413861904646", + ... user_name="bjensen@example.com", + ... meta=Meta( + ... resource_type="User", + ... created=datetime.datetime( + ... 2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc + ... ), + ... ), + ... ) + + >>> user[EnterpriseUser] = EnterpriseUser(employee_number = "701984") + >>> user[EnterpriseUser].division="Theme Park" + >>> dump = user.model_dump() + >>> assert dump == { + ... "schemas": [ + ... "urn:ietf:params:scim:schemas:core:2.0:User", + ... "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + ... ], + ... "id": "2819c223-7f76-453a-919d-413861904646", + ... "meta": { + ... "resourceType": "User", + ... "created": "2010-01-23T04:56:22Z" + ... }, + ... "userName": "bjensen@example.com", + ... "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { + ... "employeeNumber": "701984", + ... "division": "Theme Park", + ... } + ... } + + +Errors and Exceptions +===================== + +scim2-models provides a hierarchy of exceptions corresponding to :rfc:`RFC7644 §3.12 <7644#section-3.12>` error types. +Each exception can be converted to an :class:`~scim2_models.Error` response object or used in Pydantic validators. + +Raising exceptions +^^^^^^^^^^^^^^^^^^ + +Exceptions are named after their ``scimType`` value: + +.. code-block:: python + + >>> from scim2_models import InvalidPathException, PathNotFoundException + + >>> raise InvalidPathException(path="invalid..path") + Traceback (most recent call last): + ... + scim2_models.exceptions.InvalidPathException: The path attribute was invalid or malformed + + >>> raise PathNotFoundException(path="unknownAttr") + Traceback (most recent call last): + ... + scim2_models.exceptions.PathNotFoundException: The specified path references a non-existent field + +Converting to Error response +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Use :meth:`~scim2_models.SCIMException.to_error` to convert an exception to an :class:`~scim2_models.Error` response: + +.. code-block:: python + + >>> from scim2_models import InvalidPathException + + >>> exc = InvalidPathException(path="invalid..path") + >>> error = exc.to_error() + >>> error.status + 400 + >>> error.scim_type + 'invalidPath' + +Converting from ValidationError +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Use :meth:`Error.from_validation_error ` to convert a single Pydantic error to an :class:`~scim2_models.Error`: + +.. code-block:: python + + >>> from pydantic import ValidationError + >>> from scim2_models import Error, User + >>> from scim2_models.base import Context + + >>> try: + ... User.model_validate({"userName": None}, scim_ctx=Context.RESOURCE_CREATION_REQUEST) + ... except ValidationError as exc: + ... error = Error.from_validation_error(exc.errors()[0]) + >>> error.scim_type + 'invalidValue' + +Use :meth:`Error.from_validation_errors ` to convert all errors at once: + +.. code-block:: python + + >>> try: + ... User.model_validate({"userName": 123, "displayName": 456}) + ... except ValidationError as exc: + ... errors = Error.from_validation_errors(exc) + >>> len(errors) + 2 + >>> [e.detail for e in errors] + ['Input should be a valid string: username', 'Input should be a valid string: displayname'] + +The exhaustive list of exceptions is available in the :class:`reference `. + +Custom models +============= + +You can write your own model and use it the same way as the other scim2-models models. +Just inherit from :class:`~scim2_models.Resource` for your main resource, or :class:`~scim2_models.Extension` for extensions. +Use :class:`~scim2_models.ComplexAttribute` as base class for complex attributes: + +.. code-block:: python + + >>> from typing import Annotated, Optional + >>> from scim2_models import Resource, Returned, Mutability, ComplexAttribute, URN + >>> from enum import Enum + + >>> class PetType(ComplexAttribute): + ... type: Optional[str] + ... """The pet type like 'cat' or 'dog'.""" + ... + ... color: Optional[str] + ... """The pet color.""" + + >>> class Pet(Resource): + ... __schema__ = URN("urn:example:schemas:Pet") + ... + ... name: Annotated[Optional[str], Mutability.immutable, Returned.always] + ... """The name of the pet.""" + ... + ... pet_type: Optional[PetType] + ... """The pet type.""" + +You can annotate fields to indicate their :class:`~scim2_models.Mutability` and :class:`~scim2_models.Returned`. +If unset the default values will be :attr:`~scim2_models.Mutability.read_write` and :attr:`~scim2_models.Returned.default`. + +.. warning:: + + Be sure to make all the fields of your model :data:`~typing.Optional`. + There will always be a :class:`~scim2_models.Context` in which this will be true. + +There is a dedicated type for :rfc:`RFC7643 §2.3.7 <7643#section-2.3.7>` :class:`~scim2_models.Reference` +that can take type parameters to represent :rfc:`RFC7643 §7 'referenceTypes'<7643#section-7>`: + +.. code-block:: python + + >>> from scim2_models import Reference + >>> class PetOwner(Resource): + ... pet: Optional[Reference["Pet"]] + +:class:`~scim2_models.Reference` has two special type parameters :class:`~scim2_models.External` and :class:`~scim2_models.URI` that matches :rfc:`RFC7643 §7 <7643#section-7>` external and URI reference types. + +Dynamic schemas from models +=========================== + +With :meth:`Resource.to_schema ` and :meth:`Extension.to_schema `, any model can be exported as a :class:`~scim2_models.Schema` object. +This is useful for server implementations, so custom models or models provided by scim2-models can easily be exported on the ``/Schemas`` endpoint. + + +.. code-block:: python + + >>> from scim2_models import Resource, URN + + >>> class MyCustomResource(Resource): + ... """My awesome custom schema.""" + ... + ... __schema__ = URN("urn:example:schemas:MyCustomResource") + ... + ... foobar: Optional[str] + ... + >>> schema = MyCustomResource.to_schema() + >>> dump = schema.model_dump() + >>> assert dump == { + ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], + ... "id": "urn:example:schemas:MyCustomResource", + ... "name": "MyCustomResource", + ... "description": "My awesome custom schema.", + ... "attributes": [ + ... { + ... "caseExact": False, + ... "multiValued": False, + ... "mutability": "readWrite", + ... "name": "foobar", + ... "required": False, + ... "returned": "default", + ... "type": "string", + ... "uniqueness": "none", + ... }, + ... ], + ... } + +Dynamic models from schemas +=========================== + +Given a :class:`~scim2_models.Schema` object, scim2-models can dynamically generate a pythonic model to be used in your code +with the :meth:`Resource.from_schema ` and :meth:`Extension.from_schema ` methods. + +.. code-block:: python + :class: dropdown + :caption: sample + + payload = { + "id": "urn:ietf:params:scim:schemas:core:2.0:Group", + "name": "Group", + "description": "Group", + "attributes": [ + { + "name": "displayName", + "type": "string", + "multiValued": false, + "description": "A human-readable name for the Group. REQUIRED.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + ... + ], + } + schema = Schema.model_validate(payload) + Group = Resource.from_schema(schema) + my_group = Group(display_name="This is my group") + +Client applications can use this to dynamically discover server resources by browsing the ``/Schemas`` endpoint. + +.. tip:: + + Sub-Attribute models are automatically created and set as members of their parent model classes. + For instance the RFC7643 Group members sub-attribute can be accessed with ``Group.Members``. + + .. toggle:: + + .. literalinclude:: ../samples/rfc7643-8.7.1-schema-group.json + :language: json + :caption: schema-group.json + +Replace operations +================== + +When handling a ``PUT`` request, validate the incoming payload with the +:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST` context, then call +:meth:`~scim2_models.Resource.replace` against the existing resource to +verify that :attr:`~scim2_models.Mutability.immutable` attributes have not been +modified. + +.. doctest:: + + >>> from scim2_models import User, Context + >>> existing = User(user_name="bjensen") + >>> replacement = User.model_validate( + ... { + ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + ... "userName": "bjensen", + ... }, + ... scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST, + ... ) + >>> replacement.replace(existing) + +If an immutable attribute differs, a :class:`~scim2_models.MutabilityException` +is raised. + +Patch operations +================ + +:class:`~scim2_models.PatchOp` allows you to apply patch operations to modify SCIM resources. +The :meth:`~scim2_models.PatchOp.patch` method applies operations in sequence and returns whether the resource was modified. The return code is a boolean indicating whether the object has been modified by the operations. + +.. note:: + :class:`~scim2_models.PatchOp` takes a type parameter that should be the class of the resource + that is expected to be patched. + +.. code-block:: python + + >>> from scim2_models import User, PatchOp, PatchOperation + >>> user = User(user_name="john.doe", nick_name="Johnny") + + >>> payload = { + ... "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + ... "Operations": [ + ... {"op": "replace", "path": "nickName", "value": "John" }, + ... {"op": "add", "path": "emails", "value": [{"value": "john@example.com"}]}, + ... ] + ... } + >>> patch = PatchOp[User].model_validate( + ... payload, scim_ctx=Context.RESOURCE_PATCH_REQUEST + ... ) + + >>> modified = patch.patch(user) + >>> print(modified) + True + >>> print(user.nick_name) + John + >>> print(user.emails[0].value) + john@example.com + +.. warning:: + + Patch operations are validated in the :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST` + context. Make sure to validate patch operations with the correct context to + ensure proper validation of mutability and required constraints. + +Bulk operations +=============== + +:class:`~scim2_models.BulkRequest` allows you to execute multiple operations at once (bulk operations) to create, modify or delete SCIM resources (see :rfc:`RFC7644 §3.7 <7644#section-3.7>`). +The :attr:`~scim2_models.BulkRequest.operations` attribute contains multiple :class:`scim2_models.BulkOperation` that each represent a single POST, PUT, PATCH or DELETE operation. + +.. code-block:: python + + >>> from scim2_models import BulkRequest + + >>> payload = { + ... "schemas": [ + ... "urn:ietf:params:scim:api:messages:2.0:BulkRequest" + ... ], + ... "Operations": [ + ... { + ... "method": "POST", + ... "path": "/Users", + ... "bulkId": "qwerty", + ... "data": { + ... "schemas": [ + ... "urn:ietf:params:scim:schemas:core:2.0:User" + ... ], + ... "userName": "Alice" + ... } + ... }, + ... { + ... "method": "POST", + ... "path": "/Groups", + ... "bulkId": "ytrewq", + ... "data": { + ... "schemas": [ + ... "urn:ietf:params:scim:schemas:core:2.0:Group" + ... ], + ... "displayName": "Tour Guides", + ... "members": [ + ... { + ... "type": "User", + ... "value": "bulkId:qwerty" + ... } + ... ] + ... } + ... } + ... ] + ... } + >>> bulk = BulkRequest.model_validate( + ... payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST + ... ) + + >>> print(bulk.operations[0].data) + {'schemas': ['urn:ietf:params:scim:schemas:core:2.0:User'], 'userName': 'Alice'} + >>> print(bulk.operations[1].path) + /Groups diff --git a/scim2_models/messages/bulk.py b/scim2_models/messages/bulk.py index 34a5b32f..5379be4a 100644 --- a/scim2_models/messages/bulk.py +++ b/scim2_models/messages/bulk.py @@ -4,8 +4,15 @@ from pydantic import Field from pydantic import PlainSerializer +from pydantic import ValidationInfo +from pydantic import model_validator +from typing_extensions import Self +from ..annotations import Required +from ..annotations import Returned from ..attributes import ComplexAttribute +from ..context import Context +from ..exceptions import InvalidValueException from ..urn import URN from ..utils import _int_to_str from .message import Message @@ -18,7 +25,7 @@ class Method(str, Enum): patch = "PATCH" delete = "DELETE" - method: Method | None = None + method: Annotated[Method | None, Required.true] = None """The HTTP method of the current operation.""" bulk_id: str | None = None @@ -28,10 +35,10 @@ class Method(str, Enum): version: str | None = None """The current resource version.""" - path: str | None = None + path: Annotated[str | None, Returned.never] = None """The resource's relative path to the SCIM service provider's root.""" - data: Any | None = None + data: Annotated[Any | None, Returned.never] = None """The resource data as it would appear for a single SCIM POST, PUT, or PATCH operation.""" @@ -44,6 +51,60 @@ class Method(str, Enum): status: Annotated[int | None, PlainSerializer(_int_to_str)] = None """The HTTP response status code for the requested operation.""" + @model_validator(mode="after") + def validate_operation_requirements(self, info: ValidationInfo) -> Self: + """Validate operation requirements according to RFC 7644.""" + scim_ctx = info.context.get("scim") if info.context else None + if scim_ctx and Context.is_request(scim_ctx) or scim_ctx == Context.DEFAULT: + # RFC 7644 Section 3.7: "path [...] REQUIRED in a request." + if self.path is None: + raise InvalidValueException( + detail="path is required for request operations" + ).as_pydantic_error() + if self.method in ( + BulkOperation.Method.post, + BulkOperation.Method.put, + BulkOperation.Method.patch, + ): + # RFC 7644 Section 3.7: "data The resource data as it would appear for a single SCIM POST, + # PUT, or PATCH operation. REQUIRED in a request when "method" is "POST", "PUT", or "PATCH"." + if self.data is None: + raise InvalidValueException( + detail="data is required for POST, PUT, or PATCH request operations" + ).as_pydantic_error() + elif scim_ctx and Context.is_response(scim_ctx): # pragma: no branch + # RFC 7644 Section 3.7: "location The resource endpoint URL. REQUIRED in a response, + # except in the event of a POST failure." + if self.location is None and not ( + self.method == BulkOperation.Method.post + and self.status is not None + and self.status >= 400 + ): + raise InvalidValueException( + detail="location is required for response" + ).as_pydantic_error() + + # RFC 7644 Section 3.7: "When indicating a response with an HTTP status + # other than a 200-series response, the response body MUST be included. + # [...] When indicating an error, the "response" attribute MUST contain + # the detail error response + if ( + self.status is not None + and self.status >= 400 + and not (self.response and self.response.get("detail")) + ): + raise InvalidValueException( + detail="response error detail is required" + ).as_pydantic_error() + + # RFC 7644 Section 3.7: "bulkId [...] REQUIRED when "method" is "POST"." + if self.method == BulkOperation.Method.post and self.bulk_id is None: + raise InvalidValueException( + detail="bulkId is required for POST operations" + ).as_pydantic_error() + + return self + class BulkRequest(Message): """Bulk request as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`. @@ -80,7 +141,7 @@ class BulkRequest(Message): will accept before the operation is terminated and an error response is returned.""" - operations: list[BulkOperation] | None = Field( + operations: Annotated[list[BulkOperation] | None, Required.true] = Field( None, serialization_alias="Operations" ) """Defines operations within a bulk job.""" @@ -99,7 +160,7 @@ class BulkResponse(Message): __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:BulkResponse") - operations: list[BulkOperation] | None = Field( + operations: Annotated[list[BulkOperation] | None, Required.true] = Field( None, serialization_alias="Operations" ) """Defines operations within a bulk job.""" diff --git a/scim2_models/resources/resource.py b/scim2_models/resources/resource.py index 82b830db..f1bb1a62 100644 --- a/scim2_models/resources/resource.py +++ b/scim2_models/resources/resource.py @@ -32,6 +32,7 @@ from ..base import BaseModel from ..context import Context from ..exceptions import InvalidPathException +from ..exceptions import InvalidValueException from ..path import Path from ..policy import ScimPolicy from ..policy import _policy @@ -397,6 +398,17 @@ def _validate_extension_schemas( return obj + @model_validator(mode="after") + def validate_resource_requirements(self) -> Self: + # RFC 7643 Section 3.1: "The string "bulkId" is a reserved keyword and + # MUST NOT be used within any unique identifier value." + if self.id and "bulkId" in self.id: + raise InvalidValueException( + detail="'bulkId' is reserved for bulk operations" + ).as_pydantic_error() + + return self + @classmethod def to_schema(cls) -> "Schema": """Build a :class:`~scim2_models.Schema` from the current resource class.""" diff --git a/tests/test_bulk.py b/tests/test_bulk.py new file mode 100644 index 00000000..b10976ce --- /dev/null +++ b/tests/test_bulk.py @@ -0,0 +1,297 @@ +import pytest +from pydantic import ValidationError + +from scim2_models.base import Context +from scim2_models.messages.bulk import BulkOperation +from scim2_models.messages.bulk import BulkRequest +from scim2_models.messages.bulk import BulkResponse +from scim2_models.messages.patch_op import PatchOp +from scim2_models.messages.patch_op import PatchOperation +from scim2_models.resources.group import Group +from scim2_models.resources.group import GroupMember +from scim2_models.resources.user import User + + +def test_operations_required_for_bulk_request(): + with pytest.raises(ValidationError): + BulkRequest.model_validate( + {"operations": None}, context={"scim": Context.RESOURCE_CREATION_REQUEST} + ) + + +def test_operations_required_for_bulk_response(): + with pytest.raises(ValidationError): + BulkResponse.model_validate( + {"operations": None}, context={"scim": Context.RESOURCE_CREATION_REQUEST} + ) + + +def test_bulkId_required_for_post_bulk_operations(): + """Test that bulkId is required for POST bulk operations. + + :rfc:`RFC7644` §3.7 <7644#section-3.7>: "bulkId [is] REQUIRED when "method" is "POST"." + """ + BulkOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulk_id": "qwerty", + "path": "/Users", + "data": {"displayName": "John Doe"}, + }, + context={"scim": Context.RESOURCE_CREATION_REQUEST}, + ) + with pytest.raises(ValidationError): + BulkOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulk_id": None, + "path": "/Users", + "data": {"displayName": "John Doe"}, + }, + context={"scim": Context.RESOURCE_CREATION_REQUEST}, + ) + + +def test_path_required_for_request_bulk_operations(): + """Test that path is required for request bulk operations. + + :rfc:`RFC7644` §3.7 <7644#section-3.7>: "path [...] REQUIRED in a request." + """ + BulkOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulk_id": "qwerty", + "path": "/Users", + "data": {"displayName": "John Doe"}, + }, + context={"scim": Context.RESOURCE_CREATION_REQUEST}, + ) + with pytest.raises(ValidationError): + BulkOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulk_id": "qwerty", + "path": None, + "data": {"displayName": "John Doe"}, + }, + context={"scim": Context.RESOURCE_CREATION_REQUEST}, + ) + BulkOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulk_id": "qwerty", + "path": None, + "location": "https://example.com/users/2819c223-7f76-453a-919d-413861904646", + "status": 201, + }, + context={"scim": Context.RESOURCE_CREATION_RESPONSE}, + ) + + +def test_data_required_for_post_put_patch_request_bulk_operations(): + """Test that data is required for POST, PUT, PATCH request bulk operations. + + :rfc:`RFC7644` §3.7 <7644#section-3.7>: "data The resource data as it would appear for a single SCIM POST, + PUT, or PATCH operation. REQUIRED in a request when "method" is "POST", "PUT", or "PATCH"." + """ + BulkOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulk_id": "qwerty", + "path": "/Users", + "data": {"displayName": "John Doe"}, + }, + context={"scim": Context.RESOURCE_CREATION_REQUEST}, + ) + BulkOperation.model_validate( + { + "method": BulkOperation.Method.patch, + "bulk_id": "qwerty", + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": {"displayName": "John Doe"}, + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + BulkOperation.model_validate( + { + "method": BulkOperation.Method.put, + "bulk_id": "qwerty", + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": {"displayName": "John Doe"}, + }, + context={"scim": Context.RESOURCE_REPLACEMENT_REQUEST}, + ) + BulkOperation.model_validate( + { + "method": BulkOperation.Method.delete, + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + }, + context={"scim": Context.DEFAULT}, + ) + with pytest.raises(ValidationError): + BulkOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulk_id": "qwerty", + "path": "/Users", + "data": None, + }, + context={"scim": Context.RESOURCE_CREATION_REQUEST}, + ) + with pytest.raises(ValidationError): + BulkOperation.model_validate( + { + "method": BulkOperation.Method.patch, + "bulk_id": "qwerty", + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": None, + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + with pytest.raises(ValidationError): + BulkOperation.model_validate( + { + "method": BulkOperation.Method.put, + "bulk_id": "qwerty", + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": None, + }, + context={"scim": Context.RESOURCE_REPLACEMENT_REQUEST}, + ) + + +def test_location_required_for_response_bulk_operations_except_post_errors(): + """Test that location is required for response bulk operations except POST errors. + + :rfc:`RFC7644` §3.7 <7644#section-3.7>: "location The resource endpoint URL. REQUIRED in a response, + except in the event of a POST failure." + """ + BulkOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulk_id": "qwerty", + "location": "https://example.com/users/2819c223-7f76-453a-919d-413861904646", + "status": 201, + }, + context={"scim": Context.RESOURCE_CREATION_RESPONSE}, + ) + BulkOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulk_id": "qwerty", + "location": None, + "status": 400, + "response": { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": 400, + "detail": "Error", + }, + }, + context={"scim": Context.RESOURCE_CREATION_RESPONSE}, + ) + with pytest.raises(ValidationError): + BulkOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulk_id": "qwerty", + "location": None, + "status": 201, + }, + context={"scim": Context.RESOURCE_CREATION_RESPONSE}, + ) + with pytest.raises(ValidationError): + BulkOperation.model_validate( + { + "method": BulkOperation.Method.patch, + "bulk_id": "qwerty", + "location": None, + "status": 400, + }, + context={"scim": Context.RESOURCE_PATCH_RESPONSE}, + ) + + +def test_method_required_for_bulk_operations(): + """Test that method is required for bulk operations.""" + with pytest.raises(ValidationError): + BulkOperation.model_validate( + { + "bulk_id": "qwerty", + "path": "/Users", + "data": {"displayName": "John Doe"}, + }, + context={"scim": Context.RESOURCE_CREATION_REQUEST}, + ) + + +def test_error_detail_required_in_response(): + BulkOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulk_id": "qwerty", + "status": 400, + "response": { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": 400, + "detail": "Error", + }, + }, + context={"scim": Context.RESOURCE_CREATION_RESPONSE}, + ) + with pytest.raises(ValidationError): + BulkOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulk_id": "qwerty", + "status": 400, + }, + context={"scim": Context.RESOURCE_CREATION_RESPONSE}, + ) + with pytest.raises(ValidationError): + BulkOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulk_id": "qwerty", + "status": 400, + "response": { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": 400, + }, + }, + context={"scim": Context.RESOURCE_CREATION_RESPONSE}, + ) + + +def test_bulk_operation_with_group(): + group = Group( + display_name="Group 1", + members=[GroupMember(value="123", display="Test User")], + ) + BulkOperation.model_validate( + { + "method": BulkOperation.Method.post, + "bulk_id": "qwerty", + "path": "/Groups", + "data": group, + }, + context={"scim": Context.RESOURCE_CREATION_REQUEST}, + ) + + +def test_bulk_operation_with_patch_operation(): + patch = PatchOp[User]( + operations=[ + PatchOperation[User]( + op=PatchOperation.Op.add, path="nickName", value="Babs" + ) + ] + ) + BulkOperation.model_validate( + { + "method": BulkOperation.Method.patch, + "bulk_id": "qwerty", + "path": "/Users", + "data": patch, + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) diff --git a/tests/test_model_validation.py b/tests/test_model_validation.py index 0cec09f4..6aa82030 100644 --- a/tests/test_model_validation.py +++ b/tests/test_model_validation.py @@ -41,6 +41,23 @@ class ReqResource(Resource): optional: Annotated[str | None, Required.false] = None +def test_validate_bulkId_not_in_resource_id(): + """Test that the reserved keyword "bulkId" is not present in any resource id. + + :rfc:`RFC7643` §3.1 <7643#section-3.1>: "The string 'bulkId' is a reserved keyword + and MUST NOT be used within any unique identifier value." + """ + with pytest.raises( + ValidationError, match="'bulkId' is reserved for bulk operations" + ): + Resource.model_validate( + { + "schemas": ["org:example:Resource"], + "id": "bulkId:foo", + }, + ) + + def test_validate_default_mutability(): """Test query validation for resource creation request.""" assert MutResource.model_validate( From 694f1dab25ab2fc83b546c5a2e30127d59f92e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Mon, 7 Sep 2026 15:42:11 +0200 Subject: [PATCH 02/15] test: add test for delete bulk operation and rename the bulkId parameter in the payload --- tests/test_bulk.py | 58 ++++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/tests/test_bulk.py b/tests/test_bulk.py index b10976ce..f919d42f 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -12,6 +12,15 @@ from scim2_models.resources.user import User +def test_bulk_operation_delete(): + BulkOperation.model_validate( + { + "method": BulkOperation.Method.delete, + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + } + ) + + def test_operations_required_for_bulk_request(): with pytest.raises(ValidationError): BulkRequest.model_validate( @@ -34,7 +43,7 @@ def test_bulkId_required_for_post_bulk_operations(): BulkOperation.model_validate( { "method": BulkOperation.Method.post, - "bulk_id": "qwerty", + "bulkId": "qwerty", "path": "/Users", "data": {"displayName": "John Doe"}, }, @@ -44,7 +53,7 @@ def test_bulkId_required_for_post_bulk_operations(): BulkOperation.model_validate( { "method": BulkOperation.Method.post, - "bulk_id": None, + "bulkId": None, "path": "/Users", "data": {"displayName": "John Doe"}, }, @@ -60,7 +69,7 @@ def test_path_required_for_request_bulk_operations(): BulkOperation.model_validate( { "method": BulkOperation.Method.post, - "bulk_id": "qwerty", + "bulkId": "qwerty", "path": "/Users", "data": {"displayName": "John Doe"}, }, @@ -70,7 +79,7 @@ def test_path_required_for_request_bulk_operations(): BulkOperation.model_validate( { "method": BulkOperation.Method.post, - "bulk_id": "qwerty", + "bulkId": "qwerty", "path": None, "data": {"displayName": "John Doe"}, }, @@ -79,7 +88,7 @@ def test_path_required_for_request_bulk_operations(): BulkOperation.model_validate( { "method": BulkOperation.Method.post, - "bulk_id": "qwerty", + "bulkId": "qwerty", "path": None, "location": "https://example.com/users/2819c223-7f76-453a-919d-413861904646", "status": 201, @@ -97,7 +106,7 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): BulkOperation.model_validate( { "method": BulkOperation.Method.post, - "bulk_id": "qwerty", + "bulkId": "qwerty", "path": "/Users", "data": {"displayName": "John Doe"}, }, @@ -106,7 +115,7 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): BulkOperation.model_validate( { "method": BulkOperation.Method.patch, - "bulk_id": "qwerty", + "bulkId": "qwerty", "path": "/Users/2819c223-7f76-453a-919d-413861904646", "data": {"displayName": "John Doe"}, }, @@ -115,24 +124,17 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): BulkOperation.model_validate( { "method": BulkOperation.Method.put, - "bulk_id": "qwerty", + "bulkId": "qwerty", "path": "/Users/2819c223-7f76-453a-919d-413861904646", "data": {"displayName": "John Doe"}, }, context={"scim": Context.RESOURCE_REPLACEMENT_REQUEST}, ) - BulkOperation.model_validate( - { - "method": BulkOperation.Method.delete, - "path": "/Users/2819c223-7f76-453a-919d-413861904646", - }, - context={"scim": Context.DEFAULT}, - ) with pytest.raises(ValidationError): BulkOperation.model_validate( { "method": BulkOperation.Method.post, - "bulk_id": "qwerty", + "bulkId": "qwerty", "path": "/Users", "data": None, }, @@ -142,7 +144,7 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): BulkOperation.model_validate( { "method": BulkOperation.Method.patch, - "bulk_id": "qwerty", + "bulkId": "qwerty", "path": "/Users/2819c223-7f76-453a-919d-413861904646", "data": None, }, @@ -152,7 +154,7 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): BulkOperation.model_validate( { "method": BulkOperation.Method.put, - "bulk_id": "qwerty", + "bulkId": "qwerty", "path": "/Users/2819c223-7f76-453a-919d-413861904646", "data": None, }, @@ -169,7 +171,7 @@ def test_location_required_for_response_bulk_operations_except_post_errors(): BulkOperation.model_validate( { "method": BulkOperation.Method.post, - "bulk_id": "qwerty", + "bulkId": "qwerty", "location": "https://example.com/users/2819c223-7f76-453a-919d-413861904646", "status": 201, }, @@ -178,7 +180,7 @@ def test_location_required_for_response_bulk_operations_except_post_errors(): BulkOperation.model_validate( { "method": BulkOperation.Method.post, - "bulk_id": "qwerty", + "bulkId": "qwerty", "location": None, "status": 400, "response": { @@ -193,7 +195,7 @@ def test_location_required_for_response_bulk_operations_except_post_errors(): BulkOperation.model_validate( { "method": BulkOperation.Method.post, - "bulk_id": "qwerty", + "bulkId": "qwerty", "location": None, "status": 201, }, @@ -203,7 +205,7 @@ def test_location_required_for_response_bulk_operations_except_post_errors(): BulkOperation.model_validate( { "method": BulkOperation.Method.patch, - "bulk_id": "qwerty", + "bulkId": "qwerty", "location": None, "status": 400, }, @@ -216,7 +218,7 @@ def test_method_required_for_bulk_operations(): with pytest.raises(ValidationError): BulkOperation.model_validate( { - "bulk_id": "qwerty", + "bulkId": "qwerty", "path": "/Users", "data": {"displayName": "John Doe"}, }, @@ -228,7 +230,7 @@ def test_error_detail_required_in_response(): BulkOperation.model_validate( { "method": BulkOperation.Method.post, - "bulk_id": "qwerty", + "bulkId": "qwerty", "status": 400, "response": { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], @@ -242,7 +244,7 @@ def test_error_detail_required_in_response(): BulkOperation.model_validate( { "method": BulkOperation.Method.post, - "bulk_id": "qwerty", + "bulkId": "qwerty", "status": 400, }, context={"scim": Context.RESOURCE_CREATION_RESPONSE}, @@ -251,7 +253,7 @@ def test_error_detail_required_in_response(): BulkOperation.model_validate( { "method": BulkOperation.Method.post, - "bulk_id": "qwerty", + "bulkId": "qwerty", "status": 400, "response": { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], @@ -270,7 +272,7 @@ def test_bulk_operation_with_group(): BulkOperation.model_validate( { "method": BulkOperation.Method.post, - "bulk_id": "qwerty", + "bulkId": "qwerty", "path": "/Groups", "data": group, }, @@ -289,7 +291,7 @@ def test_bulk_operation_with_patch_operation(): BulkOperation.model_validate( { "method": BulkOperation.Method.patch, - "bulk_id": "qwerty", + "bulkId": "qwerty", "path": "/Users", "data": patch, }, From f5a168c4a7a037150bacb4d0181f6817168f085d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Mon, 7 Sep 2026 15:42:55 +0200 Subject: [PATCH 03/15] fix: better bulk operation model validation --- scim2_models/messages/bulk.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/scim2_models/messages/bulk.py b/scim2_models/messages/bulk.py index 5379be4a..a1c4713b 100644 --- a/scim2_models/messages/bulk.py +++ b/scim2_models/messages/bulk.py @@ -35,10 +35,10 @@ class Method(str, Enum): version: str | None = None """The current resource version.""" - path: Annotated[str | None, Returned.never] = None + path: Annotated[str | None, Returned.request] = None """The resource's relative path to the SCIM service provider's root.""" - data: Annotated[Any | None, Returned.never] = None + data: Annotated[Any | None, Returned.request] = None """The resource data as it would appear for a single SCIM POST, PUT, or PATCH operation.""" @@ -55,24 +55,24 @@ class Method(str, Enum): def validate_operation_requirements(self, info: ValidationInfo) -> Self: """Validate operation requirements according to RFC 7644.""" scim_ctx = info.context.get("scim") if info.context else None - if scim_ctx and Context.is_request(scim_ctx) or scim_ctx == Context.DEFAULT: + + if not scim_ctx or scim_ctx == Context.DEFAULT: + return self + + if Context.is_request(scim_ctx): # RFC 7644 Section 3.7: "path [...] REQUIRED in a request." if self.path is None: raise InvalidValueException( detail="path is required for request operations" ).as_pydantic_error() - if self.method in ( - BulkOperation.Method.post, - BulkOperation.Method.put, - BulkOperation.Method.patch, - ): - # RFC 7644 Section 3.7: "data The resource data as it would appear for a single SCIM POST, - # PUT, or PATCH operation. REQUIRED in a request when "method" is "POST", "PUT", or "PATCH"." - if self.data is None: - raise InvalidValueException( - detail="data is required for POST, PUT, or PATCH request operations" - ).as_pydantic_error() - elif scim_ctx and Context.is_response(scim_ctx): # pragma: no branch + + # RFC 7644 Section 3.7: "data The resource data as it would appear for a single SCIM POST, + # PUT, or PATCH operation. REQUIRED in a request when "method" is "POST", "PUT", or "PATCH"." + if self.data is None: + raise InvalidValueException( + detail="data is required for POST, PUT, or PATCH request operations" + ).as_pydantic_error() + else: # RFC 7644 Section 3.7: "location The resource endpoint URL. REQUIRED in a response, # except in the event of a POST failure." if self.location is None and not ( @@ -90,7 +90,7 @@ def validate_operation_requirements(self, info: ValidationInfo) -> Self: # the detail error response if ( self.status is not None - and self.status >= 400 + and not 200 <= self.status < 300 and not (self.response and self.response.get("detail")) ): raise InvalidValueException( From f23ed5f8abc12e8faf85ed57b56c58414dda7d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Fri, 11 Sep 2026 13:33:04 +0200 Subject: [PATCH 04/15] fix: improve bulk operation validation --- scim2_models/messages/bulk.py | 15 +++++++++------ tests/test_bulk.py | 32 ++++++++------------------------ 2 files changed, 17 insertions(+), 30 deletions(-) diff --git a/scim2_models/messages/bulk.py b/scim2_models/messages/bulk.py index a1c4713b..fa67bbe0 100644 --- a/scim2_models/messages/bulk.py +++ b/scim2_models/messages/bulk.py @@ -15,6 +15,7 @@ from ..exceptions import InvalidValueException from ..urn import URN from ..utils import _int_to_str +from .error import Error from .message import Message @@ -68,7 +69,11 @@ def validate_operation_requirements(self, info: ValidationInfo) -> Self: # RFC 7644 Section 3.7: "data The resource data as it would appear for a single SCIM POST, # PUT, or PATCH operation. REQUIRED in a request when "method" is "POST", "PUT", or "PATCH"." - if self.data is None: + if self.data is None and self.method in ( + BulkOperation.Method.post, + BulkOperation.Method.put, + BulkOperation.Method.patch, + ): raise InvalidValueException( detail="data is required for POST, PUT, or PATCH request operations" ).as_pydantic_error() @@ -85,16 +90,14 @@ def validate_operation_requirements(self, info: ValidationInfo) -> Self: ).as_pydantic_error() # RFC 7644 Section 3.7: "When indicating a response with an HTTP status - # other than a 200-series response, the response body MUST be included. - # [...] When indicating an error, the "response" attribute MUST contain - # the detail error response + # other than a 200-series response, the response body MUST be included." if ( self.status is not None and not 200 <= self.status < 300 - and not (self.response and self.response.get("detail")) + and not isinstance(self.response, Error) ): raise InvalidValueException( - detail="response error detail is required" + detail="response error parameter is required" ).as_pydantic_error() # RFC 7644 Section 3.7: "bulkId [...] REQUIRED when "method" is "POST"." diff --git a/tests/test_bulk.py b/tests/test_bulk.py index f919d42f..c31fd4cb 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -1,6 +1,7 @@ import pytest from pydantic import ValidationError +from scim2_models import Error from scim2_models.base import Context from scim2_models.messages.bulk import BulkOperation from scim2_models.messages.bulk import BulkRequest @@ -183,11 +184,9 @@ def test_location_required_for_response_bulk_operations_except_post_errors(): "bulkId": "qwerty", "location": None, "status": 400, - "response": { - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status": 400, - "detail": "Error", - }, + "response": Error( + status=400, + ), }, context={"scim": Context.RESOURCE_CREATION_RESPONSE}, ) @@ -226,17 +225,15 @@ def test_method_required_for_bulk_operations(): ) -def test_error_detail_required_in_response(): +def test_error_response_required_in_response(): BulkOperation.model_validate( { "method": BulkOperation.Method.post, "bulkId": "qwerty", "status": 400, - "response": { - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status": 400, - "detail": "Error", - }, + "response": Error( + status=400, + ), }, context={"scim": Context.RESOURCE_CREATION_RESPONSE}, ) @@ -249,19 +246,6 @@ def test_error_detail_required_in_response(): }, context={"scim": Context.RESOURCE_CREATION_RESPONSE}, ) - with pytest.raises(ValidationError): - BulkOperation.model_validate( - { - "method": BulkOperation.Method.post, - "bulkId": "qwerty", - "status": 400, - "response": { - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status": 400, - }, - }, - context={"scim": Context.RESOURCE_CREATION_RESPONSE}, - ) def test_bulk_operation_with_group(): From df83bd5f70638fa364662dc7fda37fdd9d41e6f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Fri, 11 Sep 2026 13:55:39 +0200 Subject: [PATCH 05/15] feat: add dedicated BULK_REQUEST and BULK_RESPONSE contexts --- doc/tutorial.rst | 8 ++++---- scim2_models/annotated.py | 20 ++++++++++++++++++++ scim2_models/base.py | 8 +++++++- scim2_models/context.py | 28 ++++++++++++++++++++++++++++ tests/test_bulk.py | 4 ++-- tests/test_model_serialization.py | 1 + tests/test_model_validation.py | 1 + tests/test_models.py | 4 ++-- 8 files changed, 65 insertions(+), 9 deletions(-) diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 2b8e8a31..7dd94273 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -682,10 +682,10 @@ The :attr:`~scim2_models.BulkRequest.operations` attribute contains multiple :cl ... ] ... } >>> bulk = BulkRequest.model_validate( - ... payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST + ... payload, scim_ctx=Context.BULK_REQUEST ... ) - >>> print(bulk.operations[0].data) + >>> bulk.operations[0].data {'schemas': ['urn:ietf:params:scim:schemas:core:2.0:User'], 'userName': 'Alice'} - >>> print(bulk.operations[1].path) - /Groups + >>> bulk.operations[1].path + '/Groups' diff --git a/scim2_models/annotated.py b/scim2_models/annotated.py index a46ab7fc..7389cd08 100644 --- a/scim2_models/annotated.py +++ b/scim2_models/annotated.py @@ -159,6 +159,16 @@ def serialize_with_context( Annotated[T, SCIMSerializer(Context.RESOURCE_PATCH_RESPONSE)], type_params=(T,), ) + BulkRequestContext = TypeAliasType( + "BulkRequestContext", + Annotated[T, SCIMValidator(Context.BULK_REQUEST)], + type_params=(T,), + ) + BulkResponseContext = TypeAliasType( + "BulkResponseContext", + Annotated[T, SCIMSerializer(Context.BULK_RESPONSE)], + type_params=(T,), + ) else: class _RequestContextAlias: @@ -226,3 +236,13 @@ class PatchResponseContext(_ResponseContextAlias): """Shortcut for ``Annotated[T, SCIMSerializer(Context.RESOURCE_PATCH_RESPONSE)]``.""" _ctx = Context.RESOURCE_PATCH_RESPONSE + + class BulkRequestContext(_RequestContextAlias): + """Shortcut for ``Annotated[T, SCIMValidator(Context.BULK_REQUEST)]``.""" + + _ctx = Context.BULK_REQUEST + + class BulkResponseContext(_ResponseContextAlias): + """Shortcut for ``Annotated[T, SCIMSerializer(Context.BULK_RESPONSE)]``.""" + + _ctx = Context.BULK_RESPONSE diff --git a/scim2_models/base.py b/scim2_models/base.py index bccf61ca..d2e7aa86 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -435,6 +435,7 @@ def enforce_scim_context(self, info: ValidationInfo) -> Self: is_create_or_replace = scim_context in ( Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_REPLACEMENT_REQUEST, + Context.BULK_REQUEST, ) fields_set = self.model_fields_set @@ -498,7 +499,11 @@ def _check_mutability(self, field_name: str, scim_context: Context) -> None: elif ( scim_context - in (Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_REPLACEMENT_REQUEST) + in ( + Context.RESOURCE_CREATION_REQUEST, + Context.RESOURCE_REPLACEMENT_REQUEST, + Context.BULK_REQUEST, + ) and mutability == Mutability.read_only ): # Avoid re-triggering this validation by using __dict__ @@ -726,6 +731,7 @@ def _scim_request_serializer( Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_REPLACEMENT_REQUEST, Context.RESOURCE_PATCH_REQUEST, + Context.BULK_REQUEST, ) and mutability == Mutability.read_only ): diff --git a/scim2_models/context.py b/scim2_models/context.py index 845a7c25..e275e039 100644 --- a/scim2_models/context.py +++ b/scim2_models/context.py @@ -168,6 +168,32 @@ class Context(Enum): - not dump attributes annotated with :attr:`~scim2_models.Returned.request` unless they are explicitly included. """ + BULK_REQUEST = auto() + """The bulk request context. + + Should be used for clients building a payload for a bulk request, + and servers validating bulk request payloads. + + - When used for serialization, it will not dump attributes annotated with :attr:`~scim2_models.Mutability.read_only`. + - When used for validation, it will raise a :class:`~pydantic_core.ValidationError`: + - when finding attributes annotated with :attr:`~scim2_models.Mutability.read_only`, + - when attributes annotated with :attr:`Required.true ` are missing or null. + """ + + BULK_RESPONSE = auto() + """The bulk response context. + + Should be used for servers building a payload for a bulk response, + and clients validating bulk response payloads. + + - When used for validation, it will raise a :class:`~pydantic_core.ValidationError` when finding attributes annotated with :attr:`~scim2_models.Returned.never` or when attributes annotated with :attr:`~scim2_models.Returned.always` are missing or :data:`None`; + - When used for serialization, it will: + - always dump attributes annotated with :attr:`~scim2_models.Returned.always`; + - never dump attributes annotated with :attr:`~scim2_models.Returned.never`; + - dump attributes annotated with :attr:`~scim2_models.Returned.default` unless they are explicitly excluded; + - not dump attributes annotated with :attr:`~scim2_models.Returned.request` unless they are explicitly included. + """ + @classmethod def is_request(cls, ctx: "Context") -> bool: return ctx in ( @@ -176,6 +202,7 @@ def is_request(cls, ctx: "Context") -> bool: cls.RESOURCE_REPLACEMENT_REQUEST, cls.SEARCH_REQUEST, cls.RESOURCE_PATCH_REQUEST, + cls.BULK_REQUEST, ) @classmethod @@ -186,4 +213,5 @@ def is_response(cls, ctx: "Context") -> bool: cls.RESOURCE_REPLACEMENT_RESPONSE, cls.SEARCH_RESPONSE, cls.RESOURCE_PATCH_RESPONSE, + cls.BULK_RESPONSE, ) diff --git a/tests/test_bulk.py b/tests/test_bulk.py index c31fd4cb..599fbb8d 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -25,14 +25,14 @@ def test_bulk_operation_delete(): def test_operations_required_for_bulk_request(): with pytest.raises(ValidationError): BulkRequest.model_validate( - {"operations": None}, context={"scim": Context.RESOURCE_CREATION_REQUEST} + {"operations": None}, context={"scim": Context.BULK_REQUEST} ) def test_operations_required_for_bulk_response(): with pytest.raises(ValidationError): BulkResponse.model_validate( - {"operations": None}, context={"scim": Context.RESOURCE_CREATION_REQUEST} + {"operations": None}, context={"scim": Context.BULK_REQUEST} ) diff --git a/tests/test_model_serialization.py b/tests/test_model_serialization.py index c1d420d5..f47988de 100644 --- a/tests/test_model_serialization.py +++ b/tests/test_model_serialization.py @@ -508,6 +508,7 @@ def test_invalid_excluded_attributes(): Context.RESOURCE_QUERY_RESPONSE, Context.RESOURCE_REPLACEMENT_RESPONSE, Context.SEARCH_RESPONSE, + Context.BULK_RESPONSE, ], ) def test_dump_response(context, ret_resource): diff --git a/tests/test_model_validation.py b/tests/test_model_validation.py index 6aa82030..b8e2873b 100644 --- a/tests/test_model_validation.py +++ b/tests/test_model_validation.py @@ -623,6 +623,7 @@ def test_validate_default_response_returnability(): Context.RESOURCE_QUERY_RESPONSE, Context.RESOURCE_REPLACEMENT_RESPONSE, Context.SEARCH_RESPONSE, + Context.BULK_RESPONSE, ], ) def test_validate_response_returnability(context): diff --git a/tests/test_models.py b/tests/test_models.py index 8acb6d2b..ffcd47da 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -69,8 +69,8 @@ def _error_summary(exc: ValidationError) -> list[tuple[str, tuple]]: # An error answers any request. "error": Context.RESOURCE_QUERY_RESPONSE, # A bulk exchange is a POST on /Bulk. - "bulk_request": Context.RESOURCE_CREATION_REQUEST, - "bulk_response": Context.RESOURCE_CREATION_RESPONSE, + "bulk_request": Context.BULK_REQUEST, + "bulk_response": Context.BULK_RESPONSE, } # RFC7643 §8.2 and §8.3 illustrate every attribute at once. They carry a From 41de87014871b4d47573f7b82f19c45f65f7e091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Fri, 11 Sep 2026 15:04:32 +0200 Subject: [PATCH 06/15] feat: BulkOperation, BulkRequest and BulkResponse are now generic classes, which allows for correct attribute typing --- doc/tutorial.rst | 691 ---------------------------------- scim2_models/context.py | 2 +- scim2_models/messages/bulk.py | 65 +++- tests/test_bulk.py | 128 +++++-- tests/test_models.py | 4 +- 5 files changed, 151 insertions(+), 739 deletions(-) delete mode 100644 doc/tutorial.rst diff --git a/doc/tutorial.rst b/doc/tutorial.rst deleted file mode 100644 index 7dd94273..00000000 --- a/doc/tutorial.rst +++ /dev/null @@ -1,691 +0,0 @@ -Tutorial --------- - -Attribute access -================ - -SCIM resources support two ways to access and modify attributes. -The standard Python dot notation uses snake_case attribute names, while the bracket notation accepts SCIM paths as defined in :rfc:`RFC7644 §3.10 <7644#section-3.10>`. - -.. doctest:: - - >>> from scim2_models import User - - >>> user = User(user_name="bjensen") - >>> user.display_name = "Barbara Jensen" - >>> user["nickName"] = "Babs" - >>> user["name.familyName"] = "Jensen" - -Attributes can be removed with ``del`` or by assigning :data:`None` to the attribute. - -.. doctest:: - - >>> del user["nickName"] - >>> user.nick_name is None - True - -Model parsing -============= - -Use Pydantic's :func:`~scim2_models.BaseModel.model_validate` method to parse and validate SCIM2 payloads. - - -.. code-block:: python - :emphasize-lines: 17 - - >>> from scim2_models import User - >>> import datetime - - >>> payload = { - ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - ... "id": "2819c223-7f76-453a-919d-413861904646", - ... "userName": "bjensen@example.com", - ... "meta": { - ... "resourceType": "User", - ... "created": "2010-01-23T04:56:22Z", - ... "lastModified": "2011-05-13T04:42:34Z", - ... "version": 'W\\/"3694e05e9dff590"', - ... "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", - ... }, - ... } - - >>> user = User.model_validate(payload) - >>> user.user_name - 'bjensen@example.com' - >>> user.meta.created # doctest: +ELLIPSIS - datetime.datetime(2010, 1, 23, 4, 56, 22, tzinfo=...) - -Payloads that have not been decoded yet can be handled by -:func:`~scim2_models.BaseModel.model_validate_json`. -Malformed JSON raises a :class:`~pydantic_core.ValidationError`, like any other invalid payload. - -.. code-block:: python - - >>> import json - - >>> user = User.model_validate_json(json.dumps(payload)) - >>> user.user_name - 'bjensen@example.com' - - -Model serialization -=================== - -Pydantic :func:`~scim2_models.BaseModel.model_dump` method has been tuned to produce valid SCIM2 payloads. - -.. code-block:: python - :emphasize-lines: 16 - - >>> from scim2_models import User, Meta - >>> import datetime - - >>> user = User( - ... id="2819c223-7f76-453a-919d-413861904646", - ... user_name="bjensen@example.com", - ... meta=Meta( - ... resource_type="User", - ... created=datetime.datetime(2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc), - ... last_modified=datetime.datetime(2011, 5, 13, 4, 42, 34, tzinfo=datetime.timezone.utc), - ... version='W\\/"3694e05e9dff590"', - ... location="https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", - ... ), - ... ) - - >>> dump = user.model_dump() - >>> assert dump == { - ... "schemas": [ - ... "urn:ietf:params:scim:schemas:core:2.0:User" - ... ], - ... "id": "2819c223-7f76-453a-919d-413861904646", - ... "meta": { - ... "resourceType": "User", - ... "created": "2010-01-23T04:56:22Z", - ... "lastModified": "2011-05-13T04:42:34Z", - ... "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", - ... "version": "W\\/\"3694e05e9dff590\"" - ... }, - ... "userName": "bjensen@example.com" - ... } - -Contexts -======== - -The SCIM specifications detail some :class:`~scim2_models.Mutability` and :class:`~scim2_models.Returned` parameters for model attributes. -Depending on the context, they will indicate that attributes should be present, absent, or ignored. - -For instance, attributes marked as :attr:`~scim2_models.Mutability.read_only` should not be sent by SCIM clients on resource creation requests. -By passing the right :class:`~scim2_models.Context` to the :meth:`~scim2_models.BaseModel.model_dump` method, only the expected fields will be dumped for this context: - -.. code-block:: python - :caption: Client generating a resource creation request payload - - >>> from scim2_models import User, Context - >>> user = User(user_name="bjensen@example.com") - >>> payload = user.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST) - -In the same fashion, by passing the right :class:`~scim2_models.Context` to the :meth:`~scim2_models.BaseModel.model_validate` method, -fields with unexpected values will raise :class:`~pydantic_core.ValidationError`: - -.. code-block:: python - :caption: Server validating a resource creation request payload - - >>> from scim2_models import User, Context, Error - >>> from pydantic import ValidationError - >>> try: - ... obj = User.model_validate(payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST) - ... except ValidationError: - ... obj = Error(...) - -:meth:`~scim2_models.BaseModel.model_validate_json` takes the same :paramref:`~scim2_models.BaseModel.model_validate_json.scim_ctx` parameter. - -Context annotations -=================== - -Context type aliases -^^^^^^^^^^^^^^^^^^^^ - -scim2-models provides generic type aliases that wrap -:class:`~scim2_models.SCIMValidator` and :class:`~scim2_models.SCIMSerializer` for each -SCIM context. ``*RequestContext`` aliases inject the context during **validation**, -``*ResponseContext`` aliases during **serialization**: - -- :class:`~scim2_models.CreationRequestContext` / :class:`~scim2_models.CreationResponseContext` — resource creation (``POST``) -- :class:`~scim2_models.QueryRequestContext` / :class:`~scim2_models.QueryResponseContext` — resource query (``GET``) -- :class:`~scim2_models.ReplacementRequestContext` / :class:`~scim2_models.ReplacementResponseContext` — resource replacement (``PUT``) -- :class:`~scim2_models.SearchRequestContext` / :class:`~scim2_models.SearchResponseContext` — search (``POST /…/.search``) -- :class:`~scim2_models.PatchRequestContext` / :class:`~scim2_models.PatchResponseContext` — patch (``PATCH``) - -.. code-block:: python - - >>> from pydantic import TypeAdapter - >>> from scim2_models import User, CreationRequestContext, CreationResponseContext - - >>> adapter = TypeAdapter(CreationRequestContext[User]) - >>> user = adapter.validate_python({ - ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - ... "userName": "bjensen", - ... "id": "should-be-stripped", - ... }) - >>> user.id is None - True - - >>> adapter = TypeAdapter(CreationResponseContext[User]) - >>> user.id = "123" - >>> data = adapter.dump_python(user) - >>> "password" not in data - True - -In FastAPI for instance, they can be used directly in endpoint signatures: - -.. code-block:: python - - from scim2_models import CreationRequestContext, CreationResponseContext, User - - @router.post("/Users", status_code=201) - async def create_user( - user: CreationRequestContext[User], - ) -> CreationResponseContext[User]: - ... - -See the :doc:`guides/fastapi` guide for a complete example. - -.. note:: - - ``*ResponseContext`` aliases do not support the ``attributes`` / - ``excludedAttributes`` parameters defined in - :rfc:`RFC 7644 §3.9 <7644#section-3.9>`. When you need to forward those - parameters, use ``model_dump_json`` explicitly instead. - -Low-level markers -^^^^^^^^^^^^^^^^^ - -For more advanced usage, the underlying markers can be used directly with -:data:`typing.Annotated`: - -- :class:`~scim2_models.SCIMValidator` — injects a context during **validation**. -- :class:`~scim2_models.SCIMSerializer` — injects a context during **serialization**. - -.. code-block:: python - - >>> from typing import Annotated - >>> from pydantic import TypeAdapter - >>> from scim2_models import User, Context, SCIMSerializer - - >>> adapter = TypeAdapter( - ... Annotated[User, SCIMSerializer(Context.RESOURCE_QUERY_RESPONSE)] - ... ) - >>> user = User(user_name="bjensen", password="secret") - >>> user.id = "123" - >>> data = adapter.dump_python(user) - >>> "password" not in data - True - -Attributes inclusions and exclusions -==================================== - -In some situations it might be needed to exclude, or only include a given set of attributes when serializing a model. -This happens for instance when servers build response payloads for clients requesting only a subset of the model attributes. -As defined in :rfc:`RFC7644 §3.9 <7644#section-3.9>`, :code:`attributes` and :code:`excluded_attributes` parameters can -be passed to :meth:`~scim2_models.BaseModel.model_dump`. -The expected attribute notation is the one detailed on :rfc:`RFC7644 §3.10 <7644#section-3.10>`, -like :code:`urn:ietf:params:scim:schemas:core:2.0:User:userName`, or :code:`userName` for short. - -.. code-block:: python - :emphasize-lines: 5 - - >>> from scim2_models import User, Context - >>> user = User(user_name="bjensen@example.com", display_name="bjensen") - >>> payload = user.model_dump( - ... scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - ... excluded_attributes=["displayName"] - ... ) - >>> assert payload == { - ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - ... "userName": "bjensen@example.com", - ... } - -Values read from :attr:`~scim2_models.ResponseParameters.attributes` and :attr:`~scim2_models.ResponseParameters.excluded_attributes` in :class:`~scim2_models.SearchRequest` objects can directly be used in :meth:`~scim2_models.BaseModel.model_dump`. - -Attributes inclusions and exclusions interact with attributes :class:`~scim2_models.Returned`, in the server response :class:`Contexts `: - -- attributes annotated with :attr:`~scim2_models.Returned.always` will always be dumped; -- attributes annotated with :attr:`~scim2_models.Returned.never` will never be dumped; -- attributes annotated with :attr:`~scim2_models.Returned.default` will be dumped unless being explicitly excluded; -- attributes annotated with :attr:`~scim2_models.Returned.request` will not be dumped unless being explicitly included. - -Typed ListResponse -================== - -:class:`~scim2_models.ListResponse` models take a type, or a union of types. -You must pass the type you expect in the response, e.g. -:class:`~scim2_models.ListResponse`\ [:class:`~scim2_models.User`] or -:class:`~scim2_models.ListResponse`\ [:class:`~scim2_models.User` | :class:`~scim2_models.Group`]. -If a response resource type cannot be found, a ``pydantic.ValidationError`` will be raised. - -.. code-block:: python - :emphasize-lines: 48 - - >>> from scim2_models import User, Group, ListResponse - - >>> payload = { - ... "totalResults": 2, - ... "itemsPerPage": 10, - ... "startIndex": 1, - ... "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - ... "Resources": [ - ... { - ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - ... "id": "2819c223-7f76-453a-919d-413861904646", - ... "userName": "bjensen@example.com", - ... "meta": { - ... "resourceType": "User", - ... "created": "2010-01-23T04:56:22Z", - ... "lastModified": "2011-05-13T04:42:34Z", - ... "version": 'W\\/"3694e05e9dff590"', - ... "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", - ... }, - ... }, - ... { - ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], - ... "id": "e9e30dba-f08f-4109-8486-d5c6a331660a", - ... "displayName": "Tour Guides", - ... "members": [ - ... { - ... "value": "2819c223-7f76-453a-919d-413861904646", - ... "$ref": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", - ... "display": "Babs Jensen", - ... }, - ... { - ... "value": "902c246b-6245-4190-8e05-00816be7344a", - ... "$ref": "https://example.com/v2/Users/902c246b-6245-4190-8e05-00816be7344a", - ... "display": "Mandy Pepperidge", - ... }, - ... ], - ... "meta": { - ... "resourceType": "Group", - ... "created": "2010-01-23T04:56:22Z", - ... "lastModified": "2011-05-13T04:42:34Z", - ... "version": 'W\\/"3694e05e9dff592"', - ... "location": "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a", - ... }, - ... }, - ... ], - ... } - - >>> response = ListResponse[User | Group].model_validate(payload) - >>> user, group = response.resources - >>> type(user) - - >>> type(group) - - - -Schema extensions -================= - -:rfc:`RFC7643 §3.3 <7643#section-3.3>` extensions are supported. -Any class inheriting from :class:`~scim2_models.Extension` can be passed as a :class:`~scim2_models.Resource` type parameter, e.g. ``user = User[EnterpriseUser]`` or ``user = User[EnterpriseUser | SuperHero]``. -Extensions attributes are accessed with brackets, e.g. ``user[EnterpriseUser].employee_number``, where ``user[EnterpriseUser]`` is a shortcut for ``user["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"]``. - -.. code-block:: python - - >>> import datetime - >>> from scim2_models import User, EnterpriseUser, Meta - - >>> user = User[EnterpriseUser]( - ... id="2819c223-7f76-453a-919d-413861904646", - ... user_name="bjensen@example.com", - ... meta=Meta( - ... resource_type="User", - ... created=datetime.datetime( - ... 2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc - ... ), - ... ), - ... ) - - >>> user[EnterpriseUser] = EnterpriseUser(employee_number = "701984") - >>> user[EnterpriseUser].division="Theme Park" - >>> dump = user.model_dump() - >>> assert dump == { - ... "schemas": [ - ... "urn:ietf:params:scim:schemas:core:2.0:User", - ... "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" - ... ], - ... "id": "2819c223-7f76-453a-919d-413861904646", - ... "meta": { - ... "resourceType": "User", - ... "created": "2010-01-23T04:56:22Z" - ... }, - ... "userName": "bjensen@example.com", - ... "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { - ... "employeeNumber": "701984", - ... "division": "Theme Park", - ... } - ... } - - -Errors and Exceptions -===================== - -scim2-models provides a hierarchy of exceptions corresponding to :rfc:`RFC7644 §3.12 <7644#section-3.12>` error types. -Each exception can be converted to an :class:`~scim2_models.Error` response object or used in Pydantic validators. - -Raising exceptions -^^^^^^^^^^^^^^^^^^ - -Exceptions are named after their ``scimType`` value: - -.. code-block:: python - - >>> from scim2_models import InvalidPathException, PathNotFoundException - - >>> raise InvalidPathException(path="invalid..path") - Traceback (most recent call last): - ... - scim2_models.exceptions.InvalidPathException: The path attribute was invalid or malformed - - >>> raise PathNotFoundException(path="unknownAttr") - Traceback (most recent call last): - ... - scim2_models.exceptions.PathNotFoundException: The specified path references a non-existent field - -Converting to Error response -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Use :meth:`~scim2_models.SCIMException.to_error` to convert an exception to an :class:`~scim2_models.Error` response: - -.. code-block:: python - - >>> from scim2_models import InvalidPathException - - >>> exc = InvalidPathException(path="invalid..path") - >>> error = exc.to_error() - >>> error.status - 400 - >>> error.scim_type - 'invalidPath' - -Converting from ValidationError -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Use :meth:`Error.from_validation_error ` to convert a single Pydantic error to an :class:`~scim2_models.Error`: - -.. code-block:: python - - >>> from pydantic import ValidationError - >>> from scim2_models import Error, User - >>> from scim2_models.base import Context - - >>> try: - ... User.model_validate({"userName": None}, scim_ctx=Context.RESOURCE_CREATION_REQUEST) - ... except ValidationError as exc: - ... error = Error.from_validation_error(exc.errors()[0]) - >>> error.scim_type - 'invalidValue' - -Use :meth:`Error.from_validation_errors ` to convert all errors at once: - -.. code-block:: python - - >>> try: - ... User.model_validate({"userName": 123, "displayName": 456}) - ... except ValidationError as exc: - ... errors = Error.from_validation_errors(exc) - >>> len(errors) - 2 - >>> [e.detail for e in errors] - ['Input should be a valid string: username', 'Input should be a valid string: displayname'] - -The exhaustive list of exceptions is available in the :class:`reference `. - -Custom models -============= - -You can write your own model and use it the same way as the other scim2-models models. -Just inherit from :class:`~scim2_models.Resource` for your main resource, or :class:`~scim2_models.Extension` for extensions. -Use :class:`~scim2_models.ComplexAttribute` as base class for complex attributes: - -.. code-block:: python - - >>> from typing import Annotated, Optional - >>> from scim2_models import Resource, Returned, Mutability, ComplexAttribute, URN - >>> from enum import Enum - - >>> class PetType(ComplexAttribute): - ... type: Optional[str] - ... """The pet type like 'cat' or 'dog'.""" - ... - ... color: Optional[str] - ... """The pet color.""" - - >>> class Pet(Resource): - ... __schema__ = URN("urn:example:schemas:Pet") - ... - ... name: Annotated[Optional[str], Mutability.immutable, Returned.always] - ... """The name of the pet.""" - ... - ... pet_type: Optional[PetType] - ... """The pet type.""" - -You can annotate fields to indicate their :class:`~scim2_models.Mutability` and :class:`~scim2_models.Returned`. -If unset the default values will be :attr:`~scim2_models.Mutability.read_write` and :attr:`~scim2_models.Returned.default`. - -.. warning:: - - Be sure to make all the fields of your model :data:`~typing.Optional`. - There will always be a :class:`~scim2_models.Context` in which this will be true. - -There is a dedicated type for :rfc:`RFC7643 §2.3.7 <7643#section-2.3.7>` :class:`~scim2_models.Reference` -that can take type parameters to represent :rfc:`RFC7643 §7 'referenceTypes'<7643#section-7>`: - -.. code-block:: python - - >>> from scim2_models import Reference - >>> class PetOwner(Resource): - ... pet: Optional[Reference["Pet"]] - -:class:`~scim2_models.Reference` has two special type parameters :class:`~scim2_models.External` and :class:`~scim2_models.URI` that matches :rfc:`RFC7643 §7 <7643#section-7>` external and URI reference types. - -Dynamic schemas from models -=========================== - -With :meth:`Resource.to_schema ` and :meth:`Extension.to_schema `, any model can be exported as a :class:`~scim2_models.Schema` object. -This is useful for server implementations, so custom models or models provided by scim2-models can easily be exported on the ``/Schemas`` endpoint. - - -.. code-block:: python - - >>> from scim2_models import Resource, URN - - >>> class MyCustomResource(Resource): - ... """My awesome custom schema.""" - ... - ... __schema__ = URN("urn:example:schemas:MyCustomResource") - ... - ... foobar: Optional[str] - ... - >>> schema = MyCustomResource.to_schema() - >>> dump = schema.model_dump() - >>> assert dump == { - ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], - ... "id": "urn:example:schemas:MyCustomResource", - ... "name": "MyCustomResource", - ... "description": "My awesome custom schema.", - ... "attributes": [ - ... { - ... "caseExact": False, - ... "multiValued": False, - ... "mutability": "readWrite", - ... "name": "foobar", - ... "required": False, - ... "returned": "default", - ... "type": "string", - ... "uniqueness": "none", - ... }, - ... ], - ... } - -Dynamic models from schemas -=========================== - -Given a :class:`~scim2_models.Schema` object, scim2-models can dynamically generate a pythonic model to be used in your code -with the :meth:`Resource.from_schema ` and :meth:`Extension.from_schema ` methods. - -.. code-block:: python - :class: dropdown - :caption: sample - - payload = { - "id": "urn:ietf:params:scim:schemas:core:2.0:Group", - "name": "Group", - "description": "Group", - "attributes": [ - { - "name": "displayName", - "type": "string", - "multiValued": false, - "description": "A human-readable name for the Group. REQUIRED.", - "required": false, - "caseExact": false, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - ... - ], - } - schema = Schema.model_validate(payload) - Group = Resource.from_schema(schema) - my_group = Group(display_name="This is my group") - -Client applications can use this to dynamically discover server resources by browsing the ``/Schemas`` endpoint. - -.. tip:: - - Sub-Attribute models are automatically created and set as members of their parent model classes. - For instance the RFC7643 Group members sub-attribute can be accessed with ``Group.Members``. - - .. toggle:: - - .. literalinclude:: ../samples/rfc7643-8.7.1-schema-group.json - :language: json - :caption: schema-group.json - -Replace operations -================== - -When handling a ``PUT`` request, validate the incoming payload with the -:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST` context, then call -:meth:`~scim2_models.Resource.replace` against the existing resource to -verify that :attr:`~scim2_models.Mutability.immutable` attributes have not been -modified. - -.. doctest:: - - >>> from scim2_models import User, Context - >>> existing = User(user_name="bjensen") - >>> replacement = User.model_validate( - ... { - ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - ... "userName": "bjensen", - ... }, - ... scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST, - ... ) - >>> replacement.replace(existing) - -If an immutable attribute differs, a :class:`~scim2_models.MutabilityException` -is raised. - -Patch operations -================ - -:class:`~scim2_models.PatchOp` allows you to apply patch operations to modify SCIM resources. -The :meth:`~scim2_models.PatchOp.patch` method applies operations in sequence and returns whether the resource was modified. The return code is a boolean indicating whether the object has been modified by the operations. - -.. note:: - :class:`~scim2_models.PatchOp` takes a type parameter that should be the class of the resource - that is expected to be patched. - -.. code-block:: python - - >>> from scim2_models import User, PatchOp, PatchOperation - >>> user = User(user_name="john.doe", nick_name="Johnny") - - >>> payload = { - ... "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - ... "Operations": [ - ... {"op": "replace", "path": "nickName", "value": "John" }, - ... {"op": "add", "path": "emails", "value": [{"value": "john@example.com"}]}, - ... ] - ... } - >>> patch = PatchOp[User].model_validate( - ... payload, scim_ctx=Context.RESOURCE_PATCH_REQUEST - ... ) - - >>> modified = patch.patch(user) - >>> print(modified) - True - >>> print(user.nick_name) - John - >>> print(user.emails[0].value) - john@example.com - -.. warning:: - - Patch operations are validated in the :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST` - context. Make sure to validate patch operations with the correct context to - ensure proper validation of mutability and required constraints. - -Bulk operations -=============== - -:class:`~scim2_models.BulkRequest` allows you to execute multiple operations at once (bulk operations) to create, modify or delete SCIM resources (see :rfc:`RFC7644 §3.7 <7644#section-3.7>`). -The :attr:`~scim2_models.BulkRequest.operations` attribute contains multiple :class:`scim2_models.BulkOperation` that each represent a single POST, PUT, PATCH or DELETE operation. - -.. code-block:: python - - >>> from scim2_models import BulkRequest - - >>> payload = { - ... "schemas": [ - ... "urn:ietf:params:scim:api:messages:2.0:BulkRequest" - ... ], - ... "Operations": [ - ... { - ... "method": "POST", - ... "path": "/Users", - ... "bulkId": "qwerty", - ... "data": { - ... "schemas": [ - ... "urn:ietf:params:scim:schemas:core:2.0:User" - ... ], - ... "userName": "Alice" - ... } - ... }, - ... { - ... "method": "POST", - ... "path": "/Groups", - ... "bulkId": "ytrewq", - ... "data": { - ... "schemas": [ - ... "urn:ietf:params:scim:schemas:core:2.0:Group" - ... ], - ... "displayName": "Tour Guides", - ... "members": [ - ... { - ... "type": "User", - ... "value": "bulkId:qwerty" - ... } - ... ] - ... } - ... } - ... ] - ... } - >>> bulk = BulkRequest.model_validate( - ... payload, scim_ctx=Context.BULK_REQUEST - ... ) - - >>> bulk.operations[0].data - {'schemas': ['urn:ietf:params:scim:schemas:core:2.0:User'], 'userName': 'Alice'} - >>> bulk.operations[1].path - '/Groups' diff --git a/scim2_models/context.py b/scim2_models/context.py index e275e039..c1cf147f 100644 --- a/scim2_models/context.py +++ b/scim2_models/context.py @@ -185,7 +185,7 @@ class Context(Enum): Should be used for servers building a payload for a bulk response, and clients validating bulk response payloads. - + - When used for validation, it will raise a :class:`~pydantic_core.ValidationError` when finding attributes annotated with :attr:`~scim2_models.Returned.never` or when attributes annotated with :attr:`~scim2_models.Returned.always` are missing or :data:`None`; - When used for serialization, it will: - always dump attributes annotated with :attr:`~scim2_models.Returned.always`; diff --git a/scim2_models/messages/bulk.py b/scim2_models/messages/bulk.py index fa67bbe0..43b881e3 100644 --- a/scim2_models/messages/bulk.py +++ b/scim2_models/messages/bulk.py @@ -1,6 +1,11 @@ from enum import Enum from typing import Annotated from typing import Any +from typing import Generic +from typing import TypeVar +from typing import Union +from typing import get_args +from typing import get_origin from pydantic import Field from pydantic import PlainSerializer @@ -13,13 +18,18 @@ from ..attributes import ComplexAttribute from ..context import Context from ..exceptions import InvalidValueException +from ..resources.resource import Resource from ..urn import URN +from ..utils import UNION_TYPES from ..utils import _int_to_str from .error import Error from .message import Message +from .patch_op import PatchOp +ResourceT = TypeVar("ResourceT", bound=Resource[Any]) -class BulkOperation(ComplexAttribute): + +class BulkOperation(ComplexAttribute, Generic[ResourceT]): class Method(str, Enum): post = "POST" put = "PUT" @@ -39,19 +49,38 @@ class Method(str, Enum): path: Annotated[str | None, Returned.request] = None """The resource's relative path to the SCIM service provider's root.""" - data: Annotated[Any | None, Returned.request] = None + data: Annotated[ResourceT | PatchOp[ResourceT] | None, Returned.request] = None """The resource data as it would appear for a single SCIM POST, PUT, or PATCH operation.""" location: str | None = None """The resource endpoint URL.""" - response: Any | None = None + response: ResourceT | Error | None = None """The HTTP response body for the specified request operation.""" status: Annotated[int | None, PlainSerializer(_int_to_str)] = None """The HTTP response status code for the requested operation.""" + def __class_getitem__(cls, item: Any) -> Any: + """Turn ``BulkOperation[User | Group]`` into ``BulkOperation[User] | BulkOperation[Group]``. + + A bulk job's operations can each target a different resource type, but + substituting the union directly for ``ResourceT`` would build ``data``'s + ``PatchOp[User | Group]``, which :class:`PatchOp` rejects: a PATCH + always targets one concrete resource type. + """ + # Pydantic sometimes re-subscripts an already partially-parameterized + # model (e.g. while substituting BulkRequest's own type parameter) + # by passing a 1-tuple instead of the bare value. + param = item[0] if isinstance(item, tuple) and len(item) == 1 else item + + if not isinstance(param, TypeVar) and get_origin(param) in UNION_TYPES: + members = get_args(param) + return Union[tuple(cls[member] for member in members)] # type: ignore # noqa: UP007 + + return super().__class_getitem__(item) + @model_validator(mode="after") def validate_operation_requirements(self, info: ValidationInfo) -> Self: """Validate operation requirements according to RFC 7644.""" @@ -94,10 +123,10 @@ def validate_operation_requirements(self, info: ValidationInfo) -> Self: if ( self.status is not None and not 200 <= self.status < 300 - and not isinstance(self.response, Error) + and (self.response is None or not isinstance(self.response, Error)) ): raise InvalidValueException( - detail="response error parameter is required" + detail="response parameter describing error is required" ).as_pydantic_error() # RFC 7644 Section 3.7: "bulkId [...] REQUIRED when "method" is "POST"." @@ -109,16 +138,18 @@ def validate_operation_requirements(self, info: ValidationInfo) -> Self: return self -class BulkRequest(Message): +class BulkRequest(Message, Generic[ResourceT]): """Bulk request as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`. The request groups independent SCIM operations. Its ``Operations`` field - keeps the SCIM capitalization during serialization: + keeps the SCIM capitalization during serialization. Parameterize it with + the resource type(s) the operations carry, e.g. ``BulkRequest[User | + Group]`` when a single bulk job creates both users and groups: - >>> from scim2_models import BulkOperation, BulkRequest, Context - >>> request = BulkRequest( + >>> from scim2_models import BulkOperation, BulkRequest, Context, User + >>> request = BulkRequest[User]( ... operations=[ - ... BulkOperation( + ... BulkOperation[User]( ... method="POST", ... bulk_id="create-user", ... path="/Users", @@ -126,8 +157,8 @@ class BulkRequest(Message): ... ) ... ] ... ) - >>> request.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST)["Operations"] - [{'method': 'POST', 'bulkId': 'create-user', 'path': '/Users', 'data': {'userName': 'bjensen'}}] + >>> request.model_dump(scim_ctx=Context.BULK_REQUEST)["Operations"] + [{'method': 'POST', 'bulkId': 'create-user', 'path': '/Users', 'data': {'schemas': ['urn:ietf:params:scim:schemas:core:2.0:User'], 'userName': 'bjensen'}}] scim2-models validates and serializes the message. Applying the operations it carries is left to the application. @@ -144,17 +175,19 @@ class BulkRequest(Message): will accept before the operation is terminated and an error response is returned.""" - operations: Annotated[list[BulkOperation] | None, Required.true] = Field( + operations: Annotated[list[BulkOperation[ResourceT]] | None, Required.true] = Field( None, serialization_alias="Operations" ) """Defines operations within a bulk job.""" -class BulkResponse(Message): +class BulkResponse(Message, Generic[ResourceT]): """Bulk response as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`. scim2-models validates and serializes the message. Building it from the - outcome of the operations is left to the application. + outcome of the operations is left to the application. Parameterize it + with the resource type(s) the operations carry, e.g. ``BulkResponse[User + | Group]``. .. todo:: @@ -163,7 +196,7 @@ class BulkResponse(Message): __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:BulkResponse") - operations: Annotated[list[BulkOperation] | None, Required.true] = Field( + operations: Annotated[list[BulkOperation[ResourceT]] | None, Required.true] = Field( None, serialization_alias="Operations" ) """Defines operations within a bulk job.""" diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 599fbb8d..065da35d 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -14,7 +14,7 @@ def test_bulk_operation_delete(): - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.delete, "path": "/Users/2819c223-7f76-453a-919d-413861904646", @@ -41,22 +41,22 @@ def test_bulkId_required_for_post_bulk_operations(): :rfc:`RFC7644` §3.7 <7644#section-3.7>: "bulkId [is] REQUIRED when "method" is "POST"." """ - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.post, "bulkId": "qwerty", "path": "/Users", - "data": {"displayName": "John Doe"}, + "data": User(user_name="John Doe"), }, context={"scim": Context.RESOURCE_CREATION_REQUEST}, ) with pytest.raises(ValidationError): - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.post, "bulkId": None, "path": "/Users", - "data": {"displayName": "John Doe"}, + "data": User(user_name="John Doe"), }, context={"scim": Context.RESOURCE_CREATION_REQUEST}, ) @@ -67,26 +67,26 @@ def test_path_required_for_request_bulk_operations(): :rfc:`RFC7644` §3.7 <7644#section-3.7>: "path [...] REQUIRED in a request." """ - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.post, "bulkId": "qwerty", "path": "/Users", - "data": {"displayName": "John Doe"}, + "data": User(user_name="John Doe"), }, context={"scim": Context.RESOURCE_CREATION_REQUEST}, ) with pytest.raises(ValidationError): - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.post, "bulkId": "qwerty", "path": None, - "data": {"displayName": "John Doe"}, + "data": User(user_name="John Doe"), }, context={"scim": Context.RESOURCE_CREATION_REQUEST}, ) - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.post, "bulkId": "qwerty", @@ -104,35 +104,35 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): :rfc:`RFC7644` §3.7 <7644#section-3.7>: "data The resource data as it would appear for a single SCIM POST, PUT, or PATCH operation. REQUIRED in a request when "method" is "POST", "PUT", or "PATCH"." """ - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.post, "bulkId": "qwerty", "path": "/Users", - "data": {"displayName": "John Doe"}, + "data": User(user_name="John Doe"), }, context={"scim": Context.RESOURCE_CREATION_REQUEST}, ) - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.patch, "bulkId": "qwerty", "path": "/Users/2819c223-7f76-453a-919d-413861904646", - "data": {"displayName": "John Doe"}, + "data": User(user_name="John Doe"), }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.put, "bulkId": "qwerty", "path": "/Users/2819c223-7f76-453a-919d-413861904646", - "data": {"displayName": "John Doe"}, + "data": User(user_name="John Doe"), }, context={"scim": Context.RESOURCE_REPLACEMENT_REQUEST}, ) with pytest.raises(ValidationError): - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.post, "bulkId": "qwerty", @@ -142,7 +142,7 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): context={"scim": Context.RESOURCE_CREATION_REQUEST}, ) with pytest.raises(ValidationError): - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.patch, "bulkId": "qwerty", @@ -152,7 +152,7 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) with pytest.raises(ValidationError): - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.put, "bulkId": "qwerty", @@ -169,7 +169,7 @@ def test_location_required_for_response_bulk_operations_except_post_errors(): :rfc:`RFC7644` §3.7 <7644#section-3.7>: "location The resource endpoint URL. REQUIRED in a response, except in the event of a POST failure." """ - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.post, "bulkId": "qwerty", @@ -178,7 +178,7 @@ def test_location_required_for_response_bulk_operations_except_post_errors(): }, context={"scim": Context.RESOURCE_CREATION_RESPONSE}, ) - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.post, "bulkId": "qwerty", @@ -191,7 +191,7 @@ def test_location_required_for_response_bulk_operations_except_post_errors(): context={"scim": Context.RESOURCE_CREATION_RESPONSE}, ) with pytest.raises(ValidationError): - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.post, "bulkId": "qwerty", @@ -201,7 +201,7 @@ def test_location_required_for_response_bulk_operations_except_post_errors(): context={"scim": Context.RESOURCE_CREATION_RESPONSE}, ) with pytest.raises(ValidationError): - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.patch, "bulkId": "qwerty", @@ -215,18 +215,18 @@ def test_location_required_for_response_bulk_operations_except_post_errors(): def test_method_required_for_bulk_operations(): """Test that method is required for bulk operations.""" with pytest.raises(ValidationError): - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "bulkId": "qwerty", "path": "/Users", - "data": {"displayName": "John Doe"}, + "data": User(user_name="John Doe"), }, context={"scim": Context.RESOURCE_CREATION_REQUEST}, ) def test_error_response_required_in_response(): - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.post, "bulkId": "qwerty", @@ -238,7 +238,7 @@ def test_error_response_required_in_response(): context={"scim": Context.RESOURCE_CREATION_RESPONSE}, ) with pytest.raises(ValidationError): - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.post, "bulkId": "qwerty", @@ -253,7 +253,7 @@ def test_bulk_operation_with_group(): display_name="Group 1", members=[GroupMember(value="123", display="Test User")], ) - BulkOperation.model_validate( + BulkOperation[Group].model_validate( { "method": BulkOperation.Method.post, "bulkId": "qwerty", @@ -272,7 +272,7 @@ def test_bulk_operation_with_patch_operation(): ) ] ) - BulkOperation.model_validate( + BulkOperation[User].model_validate( { "method": BulkOperation.Method.patch, "bulkId": "qwerty", @@ -281,3 +281,73 @@ def test_bulk_operation_with_patch_operation(): }, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) + + +def test_bulk_request_with_multiple_resource_types(): + """A single bulk request can create both users and groups.""" + request = BulkRequest[User | Group].model_validate( + { + "operations": [ + { + "method": BulkOperation.Method.post, + "bulkId": "create-user", + "path": "/Users", + "data": {"userName": "bjensen"}, + }, + { + "method": BulkOperation.Method.post, + "bulkId": "create-group", + "path": "/Groups", + "data": {"displayName": "Tour Guides"}, + }, + ] + }, + context={"scim": Context.BULK_REQUEST}, + ) + + assert isinstance(request.operations[0].data, User) + assert request.operations[0].data.user_name == "bjensen" + assert isinstance(request.operations[1].data, Group) + assert request.operations[1].data.display_name == "Tour Guides" + + +def test_bulk_response_with_multiple_resource_types(): + """A single bulk response can carry both user and group results.""" + response = BulkResponse[User | Group].model_validate( + { + "operations": [ + { + "method": BulkOperation.Method.post, + "bulkId": "create-user", + "location": "https://example.com/v2/Users/92b725cd-9465-4d2f-9d49-d0d8aabb54d1", + "status": 201, + "response": { + "id": "92b725cd-9465-4d2f-9d49-d0d8aabb54d1", + "userName": "bjensen", + }, + }, + { + "method": BulkOperation.Method.post, + "bulkId": "create-group", + "location": "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a", + "status": 201, + "response": { + "id": "e9e30dba-f08f-4109-8486-d5c6a331660a", + "displayName": "Tour Guides", + "members": [ + { + "value": "92b725cd-9465-4d2f-9d49-d0d8aabb54d1", + "display": "bjensen", + } + ], + }, + }, + ] + }, + context={"scim": Context.BULK_RESPONSE}, + ) + + assert isinstance(response.operations[0].response, User) + assert response.operations[0].response.user_name == "bjensen" + assert isinstance(response.operations[1].response, Group) + assert response.operations[1].response.display_name == "Tour Guides" diff --git a/tests/test_models.py b/tests/test_models.py index ffcd47da..c282470f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -34,8 +34,8 @@ def _error_summary(exc: ValidationError) -> list[tuple[str, tuple]]: "service_provider_configuration": ServiceProviderConfig, "list_response": ListResponse[User | Group | Schema | ResourceType], "patch_op": PatchOp[User], - "bulk_request": BulkRequest, - "bulk_response": BulkResponse, + "bulk_request": BulkRequest[User | User[EnterpriseUser] | Group], + "bulk_response": BulkResponse[User | User[EnterpriseUser] | Group], "search_request": SearchRequest, "error": Error, } From 6da5be7314a2f68dc384531228ae301c0457bbfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Fri, 11 Sep 2026 17:36:33 +0200 Subject: [PATCH 07/15] fix: ref attribute is not required when it references an object currently being created via the 'bulkId:' prefix --- scim2_models/base.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/scim2_models/base.py b/scim2_models/base.py index d2e7aa86..633c041d 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -445,7 +445,9 @@ def enforce_scim_context(self, info: ValidationInfo) -> Self: if Context.is_request(scim_context): if field_name in fields_set: self._check_mutability(field_name, scim_context) - if is_create_or_replace: + if is_create_or_replace and not self._is_unresolved_bulk_reference( + field_name, scim_context + ): self._check_necessity(field_name, value) else: # Must be response @@ -456,6 +458,29 @@ def enforce_scim_context(self, info: ValidationInfo) -> Self: return self + def _is_unresolved_bulk_reference( + self, field_name: str, scim_context: Context + ) -> bool: + """Whether a required Reference field targets a resource still being created. + + :rfc:`RFC7644 §3.7.2 <7644#section-3.7.2>` lets one bulk operation + reference a resource another operation in the same request is still + creating, via a ``"bulkId:"``-prefixed placeholder in the sibling + ``value`` attribute (e.g. ``manager.value``). That reference's URI + can only be resolved once the target exists, so a required Reference + sub-attribute (e.g. ``manager.$ref``) isn't checked for necessity in + this one documented case. + """ + if scim_context != Context.BULK_REQUEST: + return False + + root_type = self.__class__.get_field_root_type(field_name) + if not (isclass(root_type) and issubclass(root_type, Reference)): + return False + + sibling_value = getattr(self, "value", None) + return isinstance(sibling_value, str) and sibling_value.startswith("bulkId:") + def _raise_field_error( self, field_name: str, error: PydanticCustomError ) -> NoReturn: From 43338c69735a5ab1fec059cbd4cbb51280518f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Fri, 11 Sep 2026 17:40:49 +0200 Subject: [PATCH 08/15] fix: correct errata in samples/rfc7644-3.7.3-bulk_request-multiple_operations.json --- samples/rfc7644-3.7.3-bulk_request-multiple_operations.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/rfc7644-3.7.3-bulk_request-multiple_operations.json b/samples/rfc7644-3.7.3-bulk_request-multiple_operations.json index b12af51c..c76b88c7 100644 --- a/samples/rfc7644-3.7.3-bulk_request-multiple_operations.json +++ b/samples/rfc7644-3.7.3-bulk_request-multiple_operations.json @@ -10,7 +10,7 @@ "bulkId": "qwerty", "data": { "schemas": [ - "urn:ietf:params:scim:api:messages:2.0:User" + "urn:ietf:params:scim:schemas:core:2.0:User" ], "userName": "Alice" } From 8cca773e02f9de014f45f07d786af62c0e0f5ba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Mon, 14 Sep 2026 10:13:48 +0200 Subject: [PATCH 09/15] doc: changelog --- doc/changelog.rst | 44 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index 4f783e0e..00de70f3 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -11,15 +11,41 @@ Added :issue:`17` - PATCH paths take a value selection, such as ``emails[type eq "work"].value``. - :class:`~scim2_models.SearchRequest` and :class:`~scim2_models.ResponseParameters` take the - resource type an endpoint serves, as in ``SearchRequest[User]``, which resolves - :attr:`~scim2_models.SearchRequest.sort_by`, - :attr:`~scim2_models.ResponseParameters.attributes` and - :attr:`~scim2_models.ResponseParameters.excluded_attributes` against that model. An endpoint - covering several of them takes a union, as in ``SearchRequest[User | Group]``, and so does - :class:`~scim2_models.Path`; a path resolves against the first type declaring it, so - ``sortBy`` answers on a root query too. -- Support for bulk operations. - + resource type an endpoint serves, as in ``SearchRequest[User]``, or ``SearchRequest[User | + Group]`` for an endpoint serving several. Their :attr:`~scim2_models.SearchRequest.filter`, + :attr:`~scim2_models.SearchRequest.sort_by`, :attr:`~scim2_models.ResponseParameters.attributes` + and :attr:`~scim2_models.ResponseParameters.excluded_attributes` resolve against those models, + so a misspelled attribute is caught at validation time. +- :meth:`Path.resolve ` answers the + :class:`~scim2_models.AttributeBinding` a path designates: the model holding the attribute, its + type, its URN and its annotations. +- :meth:`ScimFilter.quote ` renders a value as a filter literal. + On Python 3.14, :class:`~scim2_models.ScimFilter` and :class:`~scim2_models.Path` take a + t-string and quote what is interpolated, so a value cannot be read as syntax. +- :class:`~scim2_models.ScimProvider` describes a SCIM service: the models it serves, and the + :class:`~scim2_models.Schema`, :class:`~scim2_models.ResourceType` and + :class:`~scim2_models.ServiceProviderConfig` objects its discovery endpoints answer. + :meth:`~scim2_models.ScimProvider.from_discovery` builds one from what a service publishes, and + a service that cannot be described is refused with + :class:`~scim2_models.ScimProviderError`. See :doc:`how-to/describe-a-scim-service`. :issue:`108` +- An extension may be declared required, as in + ``User[Annotated[EnterpriseUser, Required.true]]``. A creation or a replacement request that + leaves it out is refused. See :doc:`how-to/define-custom-models`. :issue:`105` +- :class:`~scim2_models.ScimPolicy` states how much a payload may depart from the specification + and still be read. :attr:`~scim2_models.ScimPolicy.unknown` accepts the attributes no model + declares, and :attr:`~scim2_models.ScimPolicy.remove_value_as_filter` accepts the PATCH + ``remove`` `Microsoft Entra ID + `_ + sends. Name a policy at the call, or open a ``with`` block on it + or on a provider carrying one. Every setting defaults to the strict reading, so nothing changes + until one is chosen. See :doc:`how-to/tolerate-a-nonconformant-peer`. :issue:`85` :issue:`108` +- :meth:`~scim2_models.BaseModel.model_dump` and + :meth:`~scim2_models.BaseModel.model_dump_json` take a ``response_parameters``: the + :class:`~scim2_models.ResponseParameters` a client sent, instead of its ``attributes`` and + ``excludedAttributes`` spelled out one by one. A :class:`~scim2_models.SearchRequest` is one, + so a server answering ``POST /.search`` passes the request it received. :issue:`141` +- Support for Bulk operations. :pr:`149` +- lark is a new dependency. Changed ^^^^^^^ From a25639cd925044d3d4456e8555c77157eb7d532c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Mon, 14 Sep 2026 18:00:34 +0200 Subject: [PATCH 10/15] fix: the 'bulkId' reserved keyword in resource ids is only checked in response contexts --- scim2_models/resources/resource.py | 11 ++++- tests/test_model_validation.py | 71 +++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/scim2_models/resources/resource.py b/scim2_models/resources/resource.py index f1bb1a62..d0fd4938 100644 --- a/scim2_models/resources/resource.py +++ b/scim2_models/resources/resource.py @@ -399,7 +399,16 @@ def _validate_extension_schemas( return obj @model_validator(mode="after") - def validate_resource_requirements(self) -> Self: + def validate_resource_requirements(self, info: ValidationInfo) -> Self: + """Check the identifier constraints a service provider must meet. + + The ``id`` attribute is issued by the service provider and is read-only, + so these constraints only make sense on the payloads it emits. + """ + scim_ctx = info.context.get("scim") if info.context else None + if scim_ctx is None or not Context.is_response(scim_ctx): + return self + # RFC 7643 Section 3.1: "The string "bulkId" is a reserved keyword and # MUST NOT be used within any unique identifier value." if self.id and "bulkId" in self.id: diff --git a/tests/test_model_validation.py b/tests/test_model_validation.py index b8e2873b..c854bf88 100644 --- a/tests/test_model_validation.py +++ b/tests/test_model_validation.py @@ -42,7 +42,10 @@ class ReqResource(Resource): def test_validate_bulkId_not_in_resource_id(): - """Test that the reserved keyword "bulkId" is not present in any resource id. + """Test that a response carrying the reserved keyword "bulkId" in a resource id is rejected. + + A client reading such an id cannot tell it apart from the transient + placeholder a bulk request uses to reference a resource being created. :rfc:`RFC7643` §3.1 <7643#section-3.1>: "The string 'bulkId' is a reserved keyword and MUST NOT be used within any unique identifier value." @@ -55,9 +58,75 @@ def test_validate_bulkId_not_in_resource_id(): "schemas": ["org:example:Resource"], "id": "bulkId:foo", }, + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + + +def test_validate_bulkId_anywhere_in_resource_id(): + """Test that the reserved keyword is rejected wherever it appears in a resource id. + + :rfc:`RFC7643` §3.1 <7643#section-3.1> forbids the keyword "within" an + identifier, not only as a prefix, so an opaque value cannot carry it either. + """ + with pytest.raises( + ValidationError, match="'bulkId' is reserved for bulk operations" + ): + Resource.model_validate( + { + "schemas": ["org:example:Resource"], + "id": "0e3f-bulkId-9a1c", + }, + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, ) +def test_validate_bulkId_in_resource_id_is_case_sensitive(): + """Test that a resource id differing from the reserved keyword by its case is accepted. + + :rfc:`RFC7643` §3.1 <7643#section-3.1>: the id attribute characteristics are + "caseExact" as "true", so "bulkid" is another string than the reserved keyword. + """ + resource = Resource.model_validate( + { + "schemas": ["org:example:Resource"], + "id": "bulkid:foo", + }, + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + assert resource.id == "bulkid:foo" + + +def test_validate_bulkId_in_resource_id_accepted_without_context(): + """Test that a resource validated without any context keeps a reserved id. + + Validation outside of any context accepts every field, and the constraint + describes what a service provider may emit, not what a user builds in memory. + """ + resource = Resource.model_validate( + { + "schemas": ["org:example:Resource"], + "id": "bulkId:foo", + }, + ) + assert resource.id == "bulkId:foo" + + +def test_validate_bulkId_in_resource_id_accepted_in_requests(): + """Test that a request payload carrying a reserved id is not rejected. + + The id attribute is read-only, so a request context discards it before the + constraint applies: the client is never allowed to send that value anyway. + """ + resource = Resource.model_validate( + { + "schemas": ["org:example:Resource"], + "id": "bulkId:foo", + }, + scim_ctx=Context.RESOURCE_CREATION_REQUEST, + ) + assert resource.id is None + + def test_validate_default_mutability(): """Test query validation for resource creation request.""" assert MutResource.model_validate( From 84a1fdcc623c3c92e39a8ce2ef1693969582119f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 15 Sep 2026 09:35:49 +0200 Subject: [PATCH 11/15] fix: bulk operation data is validated in the context of the request it stands for --- scim2_models/base.py | 13 +-- scim2_models/context.py | 8 ++ scim2_models/messages/bulk.py | 44 ++++++++++ tests/test_bulk.py | 150 ++++++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 5 deletions(-) diff --git a/scim2_models/base.py b/scim2_models/base.py index 633c041d..f8b0e8f9 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -437,6 +437,7 @@ def enforce_scim_context(self, info: ValidationInfo) -> Self: Context.RESOURCE_REPLACEMENT_REQUEST, Context.BULK_REQUEST, ) + in_bulk = bool(info.context.get("scim_bulk")) if info.context else False fields_set = self.model_fields_set for field_name in self.__class__.model_fields: @@ -446,7 +447,7 @@ def enforce_scim_context(self, info: ValidationInfo) -> Self: if field_name in fields_set: self._check_mutability(field_name, scim_context) if is_create_or_replace and not self._is_unresolved_bulk_reference( - field_name, scim_context + field_name, in_bulk ): self._check_necessity(field_name, value) else: @@ -458,9 +459,7 @@ def enforce_scim_context(self, info: ValidationInfo) -> Self: return self - def _is_unresolved_bulk_reference( - self, field_name: str, scim_context: Context - ) -> bool: + def _is_unresolved_bulk_reference(self, field_name: str, in_bulk: bool) -> bool: """Whether a required Reference field targets a resource still being created. :rfc:`RFC7644 §3.7.2 <7644#section-3.7.2>` lets one bulk operation @@ -470,8 +469,12 @@ def _is_unresolved_bulk_reference( can only be resolved once the target exists, so a required Reference sub-attribute (e.g. ``manager.$ref``) isn't checked for necessity in this one documented case. + + A bulk operation's data carries the context of the single request it + stands for, so the bulk job it belongs to is known from the flag + BulkOperation sets while validating it. """ - if scim_context != Context.BULK_REQUEST: + if not in_bulk: return False root_type = self.__class__.get_field_root_type(field_name) diff --git a/scim2_models/context.py b/scim2_models/context.py index c1cf147f..9cdc097c 100644 --- a/scim2_models/context.py +++ b/scim2_models/context.py @@ -174,6 +174,14 @@ class Context(Enum): Should be used for clients building a payload for a bulk request, and servers validating bulk request payloads. + This context applies to the bulk envelope: the request and the operations it + carries. Each operation's :attr:`~scim2_models.BulkOperation.data` is validated + in the context of the single request it is the payload of, as + :rfc:`RFC7644 §3.7 <7644#section-3.7>` defines it, so a POST data answers to + :attr:`RESOURCE_CREATION_REQUEST`, a PUT data to + :attr:`RESOURCE_REPLACEMENT_REQUEST` and a PATCH data to + :attr:`RESOURCE_PATCH_REQUEST`. + - When used for serialization, it will not dump attributes annotated with :attr:`~scim2_models.Mutability.read_only`. - When used for validation, it will raise a :class:`~pydantic_core.ValidationError`: - when finding attributes annotated with :attr:`~scim2_models.Mutability.read_only`, diff --git a/scim2_models/messages/bulk.py b/scim2_models/messages/bulk.py index 43b881e3..ec52ff9c 100644 --- a/scim2_models/messages/bulk.py +++ b/scim2_models/messages/bulk.py @@ -1,6 +1,7 @@ from enum import Enum from typing import Annotated from typing import Any +from typing import ClassVar from typing import Generic from typing import TypeVar from typing import Union @@ -10,6 +11,8 @@ from pydantic import Field from pydantic import PlainSerializer from pydantic import ValidationInfo +from pydantic import ValidatorFunctionWrapHandler +from pydantic import field_validator from pydantic import model_validator from typing_extensions import Self @@ -36,6 +39,13 @@ class Method(str, Enum): patch = "PATCH" delete = "DELETE" + _DATA_CONTEXTS: ClassVar[dict[Method, Context]] = { + Method.post: Context.RESOURCE_CREATION_REQUEST, + Method.put: Context.RESOURCE_REPLACEMENT_REQUEST, + Method.patch: Context.RESOURCE_PATCH_REQUEST, + } + """The single operation each method makes its data the payload of.""" + method: Annotated[Method | None, Required.true] = None """The HTTP method of the current operation.""" @@ -62,6 +72,40 @@ class Method(str, Enum): status: Annotated[int | None, PlainSerializer(_int_to_str)] = None """The HTTP response status code for the requested operation.""" + @field_validator("data", mode="wrap") + @classmethod + def _validate_data_as_a_single_operation( + cls, + value: Any, + handler: ValidatorFunctionWrapHandler, + info: ValidationInfo, + ) -> Any: + """Validate data in the context of the operation it is the payload of. + + RFC 7644 §3.7: "data The resource data as it would appear for a single + SCIM POST, PUT, or PATCH operation." A payload answers to the rules of + the request it would be sent alone in, not to those of the bulk envelope + carrying it. The envelope keeps BULK_REQUEST, and a flag carries what + stays specific to a bulk job, such as a reference to a resource another + operation is still creating. + """ + context = info.context + if not context or context.get("scim") != Context.BULK_REQUEST: + return handler(value) + + method = info.data.get("method") + derived = cls._DATA_CONTEXTS.get(method) if method else None + if derived is None: + return handler(value) + + context["scim"] = derived + context["scim_bulk"] = True + try: + return handler(value) + finally: + context["scim"] = Context.BULK_REQUEST + del context["scim_bulk"] + def __class_getitem__(cls, item: Any) -> Any: """Turn ``BulkOperation[User | Group]`` into ``BulkOperation[User] | BulkOperation[Group]``. diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 065da35d..3f0d986d 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -8,6 +8,7 @@ from scim2_models.messages.bulk import BulkResponse from scim2_models.messages.patch_op import PatchOp from scim2_models.messages.patch_op import PatchOperation +from scim2_models.resources.enterprise_user import EnterpriseUser from scim2_models.resources.group import Group from scim2_models.resources.group import GroupMember from scim2_models.resources.user import User @@ -351,3 +352,152 @@ def test_bulk_response_with_multiple_resource_types(): assert response.operations[0].response.user_name == "bjensen" assert isinstance(response.operations[1].response, Group) assert response.operations[1].response.display_name == "Tour Guides" + + +def test_patch_operation_data_answers_to_the_patch_request_rules(): + """A bulk job must not be a way to send the patches a PATCH endpoint refuses.""" + + def patch(operations): + return { + "method": BulkOperation.Method.patch, + "bulkId": "qwerty", + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": operations, + }, + } + + with pytest.raises(ValidationError, match="value is required for add operations"): + BulkOperation[User].model_validate( + patch([{"op": "add", "path": "displayName"}]), + scim_ctx=Context.BULK_REQUEST, + ) + + with pytest.raises(ValidationError, match="Remove operation requires a path"): + BulkOperation[User].model_validate( + patch([{"op": "remove"}]), scim_ctx=Context.BULK_REQUEST + ) + + with pytest.raises(ValidationError, match="a remove operation carries no value"): + BulkOperation[User].model_validate( + patch([{"op": "remove", "path": "displayName", "value": "x"}]), + scim_ctx=Context.BULK_REQUEST, + ) + + operation = BulkOperation[User].model_validate( + patch([{"op": "add", "path": "displayName", "value": "Jane"}]), + scim_ctx=Context.BULK_REQUEST, + ) + assert isinstance(operation.data, PatchOp) + + +def test_patch_operation_data_reports_missing_operations_as_a_patch_would(): + """PatchOp reports a clearer error than the generic check the bulk envelope would apply.""" + with pytest.raises(ValidationError, match="operations attribute is required"): + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.patch, + "bulkId": "qwerty", + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": {"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]}, + }, + scim_ctx=Context.BULK_REQUEST, + ) + + +def test_post_operation_data_answers_to_the_creation_request_rules(): + """A POST data is the payload of a single creation request, so it needs what a creation needs.""" + with pytest.raises(ValidationError): + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "path": "/Users", + "data": {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]}, + }, + scim_ctx=Context.BULK_REQUEST, + ) + + operation = BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "path": "/Users", + "data": { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen", + }, + }, + scim_ctx=Context.BULK_REQUEST, + ) + assert operation.data.user_name == "bjensen" + + +def test_operation_data_keeps_the_bulk_context_when_no_single_request_matches(): + """Neither a DELETE nor an unreadable method names a single request to borrow the rules from.""" + operation = BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.delete, + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen", + }, + }, + scim_ctx=Context.BULK_REQUEST, + ) + assert operation.data.user_name == "bjensen" + + +def test_operation_envelope_keeps_the_bulk_context_after_its_data(): + """The data switches the context, and the envelope still needs the bulk rules afterwards.""" + with pytest.raises(ValidationError, match="Field 'method' is required"): + BulkOperation[User].model_validate( + { + "bulkId": "qwerty", + "path": "/Users", + "data": { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen", + }, + }, + scim_ctx=Context.BULK_REQUEST, + ) + + +def test_reference_to_a_resource_being_created_stays_tolerated_in_operation_data(): + """A creation request requires a resolved reference, but RFC7644 §3.7.2 allows a placeholder inside a bulk job.""" + + def operation(manager): + return { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "path": "/Users", + "data": { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + ], + "userName": "bjensen", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { + "manager": manager + }, + }, + } + + BulkOperation[User[EnterpriseUser]].model_validate( + operation({"value": "bulkId:ytrewq"}), scim_ctx=Context.BULK_REQUEST + ) + + with pytest.raises(ValidationError): + BulkOperation[User[EnterpriseUser]].model_validate( + operation({"value": "2819c223-7f76-453a-919d-413861904646"}), + scim_ctx=Context.BULK_REQUEST, + ) + + with pytest.raises(ValidationError): + BulkOperation[User[EnterpriseUser]].model_validate( + operation({"value": "bulkId:ytrewq"}), + scim_ctx=Context.RESOURCE_CREATION_REQUEST, + ) From 7538b54149a97f65ecabd290c07595b94c965d25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 15 Sep 2026 09:48:13 +0200 Subject: [PATCH 12/15] fix: bulk models require a type parameter instead of failing on a valid attribute --- scim2_models/messages/bulk.py | 32 ++++++++++++++++++++++++++++++++ tests/test_bulk.py | 11 +++++++++-- tests/test_models.py | 8 ++++---- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/scim2_models/messages/bulk.py b/scim2_models/messages/bulk.py index ec52ff9c..1a34fecf 100644 --- a/scim2_models/messages/bulk.py +++ b/scim2_models/messages/bulk.py @@ -32,6 +32,26 @@ ResourceT = TypeVar("ResourceT", bound=Resource[Any]) +def _require_type_parameter(cls: type, name: str) -> None: + """Refuse a bulk model used without the resource type its payloads carry. + + Parameterizing builds another class, carrying __origin__ and __args__, so + the bare name reaching this check means no parameter was given. Left alone, + the type variable falls back on its bound, and a valid payload fails deep + inside on an attribute that bound does not declare, blaming an attribute + for a missing parameter. + """ + if ( + cls.__name__ == name + and not hasattr(cls, "__origin__") + and not hasattr(cls, "__args__") + ): + raise TypeError( + f"{name} requires a type parameter. " + f"Use {name}[User] or {name}[User | Group] instead of {name}." + ) + + class BulkOperation(ComplexAttribute, Generic[ResourceT]): class Method(str, Enum): post = "POST" @@ -72,6 +92,10 @@ class Method(str, Enum): status: Annotated[int | None, PlainSerializer(_int_to_str)] = None """The HTTP response status code for the requested operation.""" + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + _require_type_parameter(cls, "BulkOperation") + return super().__new__(cls) + @field_validator("data", mode="wrap") @classmethod def _validate_data_as_a_single_operation( @@ -224,6 +248,10 @@ class BulkRequest(Message, Generic[ResourceT]): ) """Defines operations within a bulk job.""" + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + _require_type_parameter(cls, "BulkRequest") + return super().__new__(cls) + class BulkResponse(Message, Generic[ResourceT]): """Bulk response as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`. @@ -244,3 +272,7 @@ class BulkResponse(Message, Generic[ResourceT]): None, serialization_alias="Operations" ) """Defines operations within a bulk job.""" + + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + _require_type_parameter(cls, "BulkResponse") + return super().__new__(cls) diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 3f0d986d..80424bc2 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -25,14 +25,14 @@ def test_bulk_operation_delete(): def test_operations_required_for_bulk_request(): with pytest.raises(ValidationError): - BulkRequest.model_validate( + BulkRequest[User].model_validate( {"operations": None}, context={"scim": Context.BULK_REQUEST} ) def test_operations_required_for_bulk_response(): with pytest.raises(ValidationError): - BulkResponse.model_validate( + BulkResponse[User].model_validate( {"operations": None}, context={"scim": Context.BULK_REQUEST} ) @@ -501,3 +501,10 @@ def operation(manager): operation({"value": "bulkId:ytrewq"}), scim_ctx=Context.RESOURCE_CREATION_REQUEST, ) + + +def test_bulk_models_require_a_type_parameter(): + """A bare model falls back on the type variable bound and blames a valid attribute for the missing parameter.""" + for model in (BulkRequest, BulkResponse, BulkOperation): + with pytest.raises(TypeError, match="requires a type parameter"): + model() diff --git a/tests/test_models.py b/tests/test_models.py index c282470f..7b82a212 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -238,8 +238,8 @@ def test_everything_is_optional(): ServiceProviderConfig, ListResponse[User], PatchOp[User], - BulkRequest, - BulkResponse, + BulkRequest[User], + BulkResponse[User], SearchRequest, Error, ] @@ -259,8 +259,8 @@ def test_json_schema_generation(): ServiceProviderConfig, ListResponse[User], PatchOp[User], - BulkRequest, - BulkResponse, + BulkRequest[User], + BulkResponse[User], SearchRequest, Error, ] From a836acc44a27fc6de02cfdaf87a988b07948fd2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 15 Sep 2026 10:41:40 +0200 Subject: [PATCH 13/15] fix: bulk rules apply in the bulk contexts, and a response requires its operations --- scim2_models/messages/bulk.py | 26 ++++++++++++-- tests/test_bulk.py | 64 ++++++++++++++++++++++------------- 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/scim2_models/messages/bulk.py b/scim2_models/messages/bulk.py index 1a34fecf..ca0bec6e 100644 --- a/scim2_models/messages/bulk.py +++ b/scim2_models/messages/bulk.py @@ -14,6 +14,7 @@ from pydantic import ValidatorFunctionWrapHandler from pydantic import field_validator from pydantic import model_validator +from pydantic_core import PydanticCustomError from typing_extensions import Self from ..annotations import Required @@ -154,10 +155,10 @@ def validate_operation_requirements(self, info: ValidationInfo) -> Self: """Validate operation requirements according to RFC 7644.""" scim_ctx = info.context.get("scim") if info.context else None - if not scim_ctx or scim_ctx == Context.DEFAULT: + if scim_ctx not in (Context.BULK_REQUEST, Context.BULK_RESPONSE): return self - if Context.is_request(scim_ctx): + if scim_ctx == Context.BULK_REQUEST: # RFC 7644 Section 3.7: "path [...] REQUIRED in a request." if self.path is None: raise InvalidValueException( @@ -276,3 +277,24 @@ class BulkResponse(Message, Generic[ResourceT]): def __new__(cls, *args: Any, **kwargs: Any) -> Self: _require_type_parameter(cls, "BulkResponse") return super().__new__(cls) + + @model_validator(mode="after") + def check_operations(self, info: ValidationInfo) -> Self: + """Validate that a bulk response carries its operations. + + :rfc:`RFC7644 §3.7 <7644#section-3.7>` makes ``Operations`` required in + a bulk response as it is in a bulk request. A response context checks + what a peer returns rather than what it must send, so the necessity of + the attribute is stated here. + """ + scim_ctx = info.context.get("scim") if info.context else None + if scim_ctx != Context.BULK_RESPONSE: + return self + + if self.operations is None: + raise PydanticCustomError( + "required_error", + "Field 'operations' is required but value is missing or null", + ) + + return self diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 80424bc2..7c52bd71 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -15,12 +15,16 @@ def test_bulk_operation_delete(): - BulkOperation[User].model_validate( + """A DELETE names its target with a path and carries no payload.""" + operation = BulkOperation[User].model_validate( { "method": BulkOperation.Method.delete, "path": "/Users/2819c223-7f76-453a-919d-413861904646", - } + }, + scim_ctx=Context.BULK_REQUEST, ) + assert operation.method == BulkOperation.Method.delete + assert operation.data is None def test_operations_required_for_bulk_request(): @@ -31,9 +35,10 @@ def test_operations_required_for_bulk_request(): def test_operations_required_for_bulk_response(): + """Required.true is not consulted in a response context, so a validator of its own states it.""" with pytest.raises(ValidationError): BulkResponse[User].model_validate( - {"operations": None}, context={"scim": Context.BULK_REQUEST} + {"operations": None}, scim_ctx=Context.BULK_RESPONSE ) @@ -49,7 +54,7 @@ def test_bulkId_required_for_post_bulk_operations(): "path": "/Users", "data": User(user_name="John Doe"), }, - context={"scim": Context.RESOURCE_CREATION_REQUEST}, + context={"scim": Context.BULK_REQUEST}, ) with pytest.raises(ValidationError): BulkOperation[User].model_validate( @@ -59,7 +64,7 @@ def test_bulkId_required_for_post_bulk_operations(): "path": "/Users", "data": User(user_name="John Doe"), }, - context={"scim": Context.RESOURCE_CREATION_REQUEST}, + context={"scim": Context.BULK_REQUEST}, ) @@ -75,7 +80,7 @@ def test_path_required_for_request_bulk_operations(): "path": "/Users", "data": User(user_name="John Doe"), }, - context={"scim": Context.RESOURCE_CREATION_REQUEST}, + context={"scim": Context.BULK_REQUEST}, ) with pytest.raises(ValidationError): BulkOperation[User].model_validate( @@ -85,7 +90,7 @@ def test_path_required_for_request_bulk_operations(): "path": None, "data": User(user_name="John Doe"), }, - context={"scim": Context.RESOURCE_CREATION_REQUEST}, + context={"scim": Context.BULK_REQUEST}, ) BulkOperation[User].model_validate( { @@ -95,7 +100,7 @@ def test_path_required_for_request_bulk_operations(): "location": "https://example.com/users/2819c223-7f76-453a-919d-413861904646", "status": 201, }, - context={"scim": Context.RESOURCE_CREATION_RESPONSE}, + context={"scim": Context.BULK_RESPONSE}, ) @@ -112,7 +117,7 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): "path": "/Users", "data": User(user_name="John Doe"), }, - context={"scim": Context.RESOURCE_CREATION_REQUEST}, + context={"scim": Context.BULK_REQUEST}, ) BulkOperation[User].model_validate( { @@ -121,7 +126,7 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): "path": "/Users/2819c223-7f76-453a-919d-413861904646", "data": User(user_name="John Doe"), }, - context={"scim": Context.RESOURCE_PATCH_REQUEST}, + context={"scim": Context.BULK_REQUEST}, ) BulkOperation[User].model_validate( { @@ -130,7 +135,7 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): "path": "/Users/2819c223-7f76-453a-919d-413861904646", "data": User(user_name="John Doe"), }, - context={"scim": Context.RESOURCE_REPLACEMENT_REQUEST}, + context={"scim": Context.BULK_REQUEST}, ) with pytest.raises(ValidationError): BulkOperation[User].model_validate( @@ -140,7 +145,7 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): "path": "/Users", "data": None, }, - context={"scim": Context.RESOURCE_CREATION_REQUEST}, + context={"scim": Context.BULK_REQUEST}, ) with pytest.raises(ValidationError): BulkOperation[User].model_validate( @@ -150,7 +155,7 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): "path": "/Users/2819c223-7f76-453a-919d-413861904646", "data": None, }, - context={"scim": Context.RESOURCE_PATCH_REQUEST}, + context={"scim": Context.BULK_REQUEST}, ) with pytest.raises(ValidationError): BulkOperation[User].model_validate( @@ -160,7 +165,7 @@ def test_data_required_for_post_put_patch_request_bulk_operations(): "path": "/Users/2819c223-7f76-453a-919d-413861904646", "data": None, }, - context={"scim": Context.RESOURCE_REPLACEMENT_REQUEST}, + context={"scim": Context.BULK_REQUEST}, ) @@ -177,7 +182,7 @@ def test_location_required_for_response_bulk_operations_except_post_errors(): "location": "https://example.com/users/2819c223-7f76-453a-919d-413861904646", "status": 201, }, - context={"scim": Context.RESOURCE_CREATION_RESPONSE}, + context={"scim": Context.BULK_RESPONSE}, ) BulkOperation[User].model_validate( { @@ -189,7 +194,7 @@ def test_location_required_for_response_bulk_operations_except_post_errors(): status=400, ), }, - context={"scim": Context.RESOURCE_CREATION_RESPONSE}, + context={"scim": Context.BULK_RESPONSE}, ) with pytest.raises(ValidationError): BulkOperation[User].model_validate( @@ -199,7 +204,7 @@ def test_location_required_for_response_bulk_operations_except_post_errors(): "location": None, "status": 201, }, - context={"scim": Context.RESOURCE_CREATION_RESPONSE}, + context={"scim": Context.BULK_RESPONSE}, ) with pytest.raises(ValidationError): BulkOperation[User].model_validate( @@ -209,7 +214,7 @@ def test_location_required_for_response_bulk_operations_except_post_errors(): "location": None, "status": 400, }, - context={"scim": Context.RESOURCE_PATCH_RESPONSE}, + context={"scim": Context.BULK_RESPONSE}, ) @@ -222,7 +227,7 @@ def test_method_required_for_bulk_operations(): "path": "/Users", "data": User(user_name="John Doe"), }, - context={"scim": Context.RESOURCE_CREATION_REQUEST}, + context={"scim": Context.BULK_REQUEST}, ) @@ -236,7 +241,7 @@ def test_error_response_required_in_response(): status=400, ), }, - context={"scim": Context.RESOURCE_CREATION_RESPONSE}, + context={"scim": Context.BULK_RESPONSE}, ) with pytest.raises(ValidationError): BulkOperation[User].model_validate( @@ -245,7 +250,7 @@ def test_error_response_required_in_response(): "bulkId": "qwerty", "status": 400, }, - context={"scim": Context.RESOURCE_CREATION_RESPONSE}, + context={"scim": Context.BULK_RESPONSE}, ) @@ -261,7 +266,7 @@ def test_bulk_operation_with_group(): "path": "/Groups", "data": group, }, - context={"scim": Context.RESOURCE_CREATION_REQUEST}, + context={"scim": Context.BULK_REQUEST}, ) @@ -280,7 +285,7 @@ def test_bulk_operation_with_patch_operation(): "path": "/Users", "data": patch, }, - context={"scim": Context.RESOURCE_PATCH_REQUEST}, + context={"scim": Context.BULK_REQUEST}, ) @@ -508,3 +513,16 @@ def test_bulk_models_require_a_type_parameter(): for model in (BulkRequest, BulkResponse, BulkOperation): with pytest.raises(TypeError, match="requires a type parameter"): model() + + +def test_bulk_rules_do_not_apply_outside_a_bulk_context(): + """A payload validated under another context is not part of a bulk job, and the bulk rules would reject it wrongly.""" + operation = BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.put, + "location": "https://example.com/v2/Users/2819c223", + "status": "200", + }, + scim_ctx=Context.SEARCH_REQUEST, + ) + assert operation.path is None From 90de0f7759661b3affb83e7f925a6f520d8743b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 15 Sep 2026 10:53:16 +0200 Subject: [PATCH 14/15] chore: export the bulk contexts, and align the bulk documentation and helpers --- doc/reference.rst | 6 ++++++ scim2_models/__init__.py | 4 ++++ scim2_models/base.py | 7 +++---- scim2_models/context.py | 4 +--- scim2_models/messages/bulk.py | 8 -------- tests/test_bulk.py | 4 ++++ 6 files changed, 18 insertions(+), 15 deletions(-) diff --git a/doc/reference.rst b/doc/reference.rst index e6af2d3b..f921d414 100644 --- a/doc/reference.rst +++ b/doc/reference.rst @@ -200,6 +200,12 @@ operation accepts or returns. .. autoclass:: scim2_models.PatchResponseContext :members: +.. autoclass:: scim2_models.BulkRequestContext + :members: + +.. autoclass:: scim2_models.BulkResponseContext + :members: + .. autoclass:: scim2_models.CaseExact :members: diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index 62fab75f..51b22bca 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -1,3 +1,5 @@ +from .annotated import BulkRequestContext +from .annotated import BulkResponseContext from .annotated import CreationRequestContext from .annotated import CreationResponseContext from .annotated import PatchRequestContext @@ -102,7 +104,9 @@ "Bulk", "BulkOperation", "BulkRequest", + "BulkRequestContext", "BulkResponse", + "BulkResponseContext", "CaseExact", "ChangePassword", "ComplexAttribute", diff --git a/scim2_models/base.py b/scim2_models/base.py index f8b0e8f9..08a79349 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -477,12 +477,11 @@ def _is_unresolved_bulk_reference(self, field_name: str, in_bulk: bool) -> bool: if not in_bulk: return False - root_type = self.__class__.get_field_root_type(field_name) - if not (isclass(root_type) and issubclass(root_type, Reference)): + sibling_value = getattr(self, "value", None) + if not (isinstance(sibling_value, str) and sibling_value.startswith("bulkId:")): return False - sibling_value = getattr(self, "value", None) - return isinstance(sibling_value, str) and sibling_value.startswith("bulkId:") + return _holds_reference(self.__class__, field_name) def _raise_field_error( self, field_name: str, error: PydanticCustomError diff --git a/scim2_models/context.py b/scim2_models/context.py index 9cdc097c..cc550d4b 100644 --- a/scim2_models/context.py +++ b/scim2_models/context.py @@ -183,9 +183,7 @@ class Context(Enum): :attr:`RESOURCE_PATCH_REQUEST`. - When used for serialization, it will not dump attributes annotated with :attr:`~scim2_models.Mutability.read_only`. - - When used for validation, it will raise a :class:`~pydantic_core.ValidationError`: - - when finding attributes annotated with :attr:`~scim2_models.Mutability.read_only`, - - when attributes annotated with :attr:`Required.true ` are missing or null. + - When used for validation, it will ignore attributes annotated with :attr:`~scim2_models.Mutability.read_only` and raise a :class:`~pydantic_core.ValidationError` when attributes annotated with :attr:`Required.true ` are missing or null. """ BULK_RESPONSE = auto() diff --git a/scim2_models/messages/bulk.py b/scim2_models/messages/bulk.py index ca0bec6e..162b2da4 100644 --- a/scim2_models/messages/bulk.py +++ b/scim2_models/messages/bulk.py @@ -231,10 +231,6 @@ class BulkRequest(Message, Generic[ResourceT]): scim2-models validates and serializes the message. Applying the operations it carries is left to the application. - - .. todo:: - - The models for Bulk operations are defined, but their behavior is not implemented nor tested yet. """ __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:BulkRequest") @@ -261,10 +257,6 @@ class BulkResponse(Message, Generic[ResourceT]): outcome of the operations is left to the application. Parameterize it with the resource type(s) the operations carry, e.g. ``BulkResponse[User | Group]``. - - .. todo:: - - The models for Bulk operations are defined, but their behavior is not implemented nor tested yet. """ __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:BulkResponse") diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 7c52bd71..ed664242 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -28,6 +28,7 @@ def test_bulk_operation_delete(): def test_operations_required_for_bulk_request(): + """A bulk request without operations describes no work at all.""" with pytest.raises(ValidationError): BulkRequest[User].model_validate( {"operations": None}, context={"scim": Context.BULK_REQUEST} @@ -232,6 +233,7 @@ def test_method_required_for_bulk_operations(): def test_error_response_required_in_response(): + """An operation that failed must say why, or the caller only learns that something went wrong.""" BulkOperation[User].model_validate( { "method": BulkOperation.Method.post, @@ -255,6 +257,7 @@ def test_error_response_required_in_response(): def test_bulk_operation_with_group(): + """A bulk job carries any resource type, so an operation must not be tied to User.""" group = Group( display_name="Group 1", members=[GroupMember(value="123", display="Test User")], @@ -271,6 +274,7 @@ def test_bulk_operation_with_group(): def test_bulk_operation_with_patch_operation(): + """A PATCH operation carries a patch rather than a resource, which the data union must accept.""" patch = PatchOp[User]( operations=[ PatchOperation[User]( From 33e119142e323a11fe4f372c908b378534351ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 15 Sep 2026 11:10:47 +0200 Subject: [PATCH 15/15] doc: document the bulk contexts and the BulkOperation model The context table of the validate-and-serialize guide promised the context each HTTP operation calls for, and listed every one but POST /Bulk. BulkOperation was autoclassed without a class docstring, so the model an application handles the most showed no description in the API reference, and nothing warned that BulkOperation[User | Group] annotates a field rather than naming a model class. --- doc/how-to/validate-and-serialize.rst | 3 +++ scim2_models/messages/bulk.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/doc/how-to/validate-and-serialize.rst b/doc/how-to/validate-and-serialize.rst index 38f7b8bc..ace64ab9 100644 --- a/doc/how-to/validate-and-serialize.rst +++ b/doc/how-to/validate-and-serialize.rst @@ -36,6 +36,9 @@ in a request context, responses are validated and serialized in the matching res * - ``POST /Users/.search`` - :attr:`~scim2_models.Context.SEARCH_REQUEST` - :attr:`~scim2_models.Context.SEARCH_RESPONSE` + * - ``POST /Bulk`` + - :attr:`~scim2_models.Context.BULK_REQUEST` + - :attr:`~scim2_models.Context.BULK_RESPONSE` :attr:`~scim2_models.Context.DEFAULT` applies neither set of rules, and suits a resource held in application state rather than exchanged over HTTP. diff --git a/scim2_models/messages/bulk.py b/scim2_models/messages/bulk.py index 162b2da4..e4b6e2ef 100644 --- a/scim2_models/messages/bulk.py +++ b/scim2_models/messages/bulk.py @@ -54,6 +54,14 @@ def _require_type_parameter(cls: type, name: str) -> None: class BulkOperation(ComplexAttribute, Generic[ResourceT]): + """One operation of a bulk job, as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`. + + ``data`` is validated in the context of the single request the operation + stands for, as :attr:`~scim2_models.Context.BULK_REQUEST` describes. + Parameterize the operation with the resource type it targets, e.g. + ``BulkOperation[User]``. + """ + class Method(str, Enum): post = "POST" put = "PUT"