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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Changelog
=========

[0.6.14] - Next release
--------------------

Added
^^^^^
- Support for bulk operations.

[0.6.13] - 2026-07-27
---------------------

Expand Down
2 changes: 1 addition & 1 deletion doc/guides/_examples/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
51 changes: 49 additions & 2 deletions doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
85 changes: 68 additions & 17 deletions scim2_models/messages/bulk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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."""

Expand All @@ -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")

Expand All @@ -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."""
12 changes: 12 additions & 0 deletions scim2_models/resources/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
Loading
Loading