From f049d6ed0327f496878a2be234463d538ad737b7 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] feat: implement bulk operations --- doc/changelog.rst | 7 + doc/guides/_examples/integrations.py | 2 +- doc/tutorial.rst | 51 ++++- scim2_models/messages/bulk.py | 85 ++++++-- scim2_models/resources/resource.py | 12 ++ tests/test_bulk.py | 297 +++++++++++++++++++++++++++ tests/test_model_validation.py | 17 ++ 7 files changed, 451 insertions(+), 20 deletions(-) create mode 100644 tests/test_bulk.py diff --git a/doc/changelog.rst b/doc/changelog.rst index 9e6dbb2..60eb57a 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,13 @@ Changelog ========= +[0.6.14] - Next release +-------------------- + +Added +^^^^^ +- Support for bulk operations. + [0.6.13] - 2026-07-27 --------------------- diff --git a/doc/guides/_examples/integrations.py b/doc/guides/_examples/integrations.py index f2c16aa..486aa0b 100644 --- a/doc/guides/_examples/integrations.py +++ b/doc/guides/_examples/integrations.py @@ -153,7 +153,7 @@ def get_resource_type(resource_type_id): service_provider_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=False, max_results=0), change_password=ChangePassword(supported=False), sort=Sort(supported=False), diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 3cd7fec..7b4ae27 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -621,6 +621,53 @@ The :meth:`~scim2_models.PatchOp.patch` method applies operations in sequence an Bulk operations =============== -.. todo:: +: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. - Bulk operations are not implemented yet, but any help is welcome! +.. 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 7c2f19f..9659daa 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 ..path 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,14 +51,63 @@ 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>`. - - .. todo:: - The models for Bulk operations are defined, but their behavior is not implemented nor tested yet. - """ +class BulkRequest(Message): + """Bulk request as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`.""" __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:BulkRequest") @@ -60,23 +116,18 @@ 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.""" class BulkResponse(Message): - """Bulk response as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`. - - .. todo:: - - The models for Bulk operations are defined, but their behavior is not implemented nor tested yet. - """ + """Bulk response as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`.""" __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 e0c13b7..b54164d 100644 --- a/scim2_models/resources/resource.py +++ b/scim2_models/resources/resource.py @@ -30,6 +30,7 @@ from ..base import BaseModel from ..context import Context from ..exceptions import InvalidPathException +from ..exceptions import InvalidValueException from ..path import Path from ..scim_object import ScimObject from ..utils import UNION_TYPES @@ -388,6 +389,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 0000000..b10976c --- /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 0c3d6f8..5e1f23e 100644 --- a/tests/test_model_validation.py +++ b/tests/test_model_validation.py @@ -36,6 +36,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(