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
16 changes: 16 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
Changelog
=========

[0.7.6] - Unreleased
--------------------

Added
^^^^^
- Support for ETags: ``replace``, ``modify`` and ``delete`` automatically send
an ``If-Match`` header when the server advertises ETag support and the resource
has a ``meta.version``. :issue:`47`

Breaking changes
^^^^^^^^^^^^^^^^
- ``delete`` now takes a resource instance instead of a resource type and id.
:issue:`13`
- ``modify`` now takes a resource instance and a patch operation instead of a
resource type, id and patch operation. :issue:`13`

[0.7.5] - 2026-04-02
--------------------

Expand Down
38 changes: 36 additions & 2 deletions doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ The :meth:`~scim2_client.BaseSyncSCIMClient.modify` method allows you to perform
patch_op = PatchOp[User](operations=[operation])

# Apply the patch
response = scim.modify(User, user_id, patch_op)
user = scim.query(User, user_id)
response = scim.modify(user, patch_op)
if response: # Server returned 200 with updated resource
print(f"User updated: {response.display_name}")
else: # Server returned 204 (no content)
Expand Down Expand Up @@ -155,7 +156,7 @@ You can include multiple operations in a single PATCH request:
)
]
patch_op = PatchOp[User](operations=operations)
response = scim.modify(User, user_id, patch_op)
response = scim.modify(user, patch_op)

Patch Operation Types
~~~~~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -201,6 +202,39 @@ To achieve this, all the methods provide the following parameters, all are :data
which value will excluded from the request payload, and which values are
expected in the response payload.

Resource versioning (ETags)
==========================

SCIM supports resource versioning through HTTP ETags
(:rfc:`RFC 7644 §3.14 <7644#section-3.14>`).
When the server advertises ETag support in its
:class:`~scim2_models.ServiceProviderConfig`, scim2-client automatically sends
an ``If-Match`` header on write operations
(:meth:`~scim2_client.BaseSyncSCIMClient.replace`,
:meth:`~scim2_client.BaseSyncSCIMClient.modify`,
:meth:`~scim2_client.BaseSyncSCIMClient.delete`)
using the :attr:`meta.version <scim2_models.Meta.version>` value from the resource.

This enables optimistic concurrency control: the server will reject the request
with ``412 Precondition Failed`` if the resource has been modified since it was
last read.

.. code-block:: python

# Read a resource — meta.version is populated by the server
user = scim.query(User, user_id)

# Modify it — If-Match is sent automatically
user.display_name = "Updated Name"
updated_user = scim.replace(user)

# Delete it — If-Match is sent automatically
scim.delete(user)

No additional configuration is needed. If the server does not advertise ETag
support, or if the resource has no :attr:`meta.version <scim2_models.Meta.version>`, no
``If-Match`` header is sent.

Engines
=======

Expand Down
115 changes: 59 additions & 56 deletions scim2_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,19 @@ def __init__(
self.check_response_status_codes = check_response_status_codes
self.raise_scim_errors = raise_scim_errors

@property
def _etag_supported(self) -> bool:
spc = self.service_provider_config
return bool(spc and spc.etag and spc.etag.supported)

def _set_if_match(self, req: RequestPayload, resource: Resource) -> None:
"""Add ``If-Match`` header to the request if the server supports ETags."""
if not self._etag_supported:
return
if resource.meta and resource.meta.version:
headers = req.request_kwargs.setdefault("headers", {})
headers.setdefault("If-Match", resource.meta.version)

def get_resource_model(self, name: str) -> type[Resource] | None:
"""Get a registered model by its name or its schema."""
for resource_model in self.resource_models:
Expand Down Expand Up @@ -515,8 +528,7 @@ def _prepare_search_request(

def _prepare_delete_request(
self,
resource_model: type[Resource],
id: str,
resource: Resource,
expected_status_codes: list[int] | None = None,
**kwargs,
) -> RequestPayload:
Expand All @@ -525,9 +537,13 @@ def _prepare_delete_request(
request_kwargs=kwargs,
)

resource_model = type(resource)
self._check_resource_model(resource_model)
delete_url = self.resource_endpoint(resource_model) + f"/{id}"
if not resource.id:
raise SCIMRequestError("Resource must have an id", source=resource)
delete_url = self.resource_endpoint(resource_model) + f"/{resource.id}"
req.url = req.request_kwargs.pop("url", delete_url)
self._set_if_match(req, resource)
return req

def _prepare_replace_request(
Expand Down Expand Up @@ -575,6 +591,7 @@ def _prepare_replace_request(
raise SCIMRequestError("Resource must have an id", source=resource)

req.expected_types = [resource.__class__]
self._set_if_match(req, resource)
req.payload = resource.model_dump(
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST
)
Expand All @@ -586,29 +603,15 @@ def _prepare_replace_request(

def _prepare_patch_request(
self,
resource_model: type[ResourceT],
id: str,
patch_op: PatchOp[ResourceT] | dict,
resource: Resource,
patch_op: PatchOp | dict,
check_request_payload: bool | None = None,
expected_status_codes: list[int] | None = None,
**kwargs,
) -> RequestPayload:
"""Prepare a PATCH request payload.

:param resource_model: The resource type to modify (e.g., User, Group).
:param id: The resource ID.
:param patch_op: A PatchOp instance parameterized with the same resource type as resource_model
(e.g., PatchOp[User] when resource_model is User), or a dict representation.
:param check_request_payload: If :data:`False`, :code:`patch_op` is expected to be a dict
that will be passed as-is in the request. This value can be
overwritten in methods.
:param expected_status_codes: List of HTTP status codes expected for this request.
:param raise_scim_errors: If :data:`True` and the server returned an
:class:`~scim2_models.Error` object during a request, a
:class:`~scim2_client.SCIMResponseErrorObject` exception will be raised.
:param kwargs: Additional request parameters.
:return: The prepared request payload.
"""
"""Prepare a PATCH request payload."""
resource_model = type(resource)
id = resource.id
req = RequestPayload(
expected_status_codes=expected_status_codes,
request_kwargs=kwargs,
Expand Down Expand Up @@ -644,12 +647,12 @@ def _prepare_patch_request(
)

req.expected_types = [resource_model]
self._set_if_match(req, resource)
return req

def modify(
self,
resource_model: type[ResourceT],
id: str,
resource: ResourceT,
patch_op: PatchOp[ResourceT] | dict,
**kwargs,
) -> ResourceT | Error | dict | None:
Expand Down Expand Up @@ -858,18 +861,19 @@ def search(

def delete(
self,
resource_model: type,
id: str,
resource: Resource,
check_response_payload: bool | None = None,
expected_status_codes: list[int]
| None = SCIMClient.DELETION_RESPONSE_STATUS_CODES,
raise_scim_errors: bool | None = None,
**kwargs,
) -> Error | dict | None:
"""Perform a DELETE request to create, as defined in :rfc:`RFC7644 §3.6 <7644#section-3.6>`.
"""Perform a DELETE request, as defined in :rfc:`RFC7644 §3.6 <7644#section-3.6>`.

:param resource_model: The type of the resource to delete.
:param id: The type id the resource to delete.
:param resource: The resource to delete. If the server supports ETags
and the resource has ``meta.version``, an ``If-Match`` header is sent.
:param resource_model: Deprecated. The type of the resource to delete.
:param id: Deprecated. The id of the resource to delete.
:param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
:param expected_status_codes: The list of expected status codes form the response.
If :data:`None` any status code is accepted.
Expand All @@ -884,11 +888,10 @@ def delete(
:usage:

.. code-block:: python
:caption: Deleting an `User` which `id` is `foobar`
:caption: Deleting a User resource

from scim2_models import User, SearchRequest

response = scim.delete(User, "foobar")
user = scim.query(User, "foobar")
response = scim.delete(resource=user)
# 'response' may be None, or an Error object
"""
raise NotImplementedError()
Expand Down Expand Up @@ -941,8 +944,7 @@ def replace(

def modify(
self,
resource_model: type[ResourceT],
id: str,
resource: ResourceT,
patch_op: PatchOp[ResourceT] | dict,
check_request_payload: bool | None = None,
check_response_payload: bool | None = None,
Expand All @@ -953,11 +955,11 @@ def modify(
) -> ResourceT | Error | dict | None:
"""Perform a PATCH request to modify a resource, as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.

:param resource_model: The type of the resource to modify.
:param id: The id of the resource to modify.
:param resource: The resource to modify. If the server supports ETags
and the resource has ``meta.version``, an ``If-Match`` header is sent.
:param patch_op: The :class:`~scim2_models.PatchOp` object describing the modifications.
Must be parameterized with the same resource type as ``resource_model``
(e.g., :code:`PatchOp[User]` when ``resource_model`` is :code:`User`).
:param resource_model: Deprecated. The type of the resource to modify.
:param id: Deprecated. The id of the resource to modify.
:param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`.
:param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
:param expected_status_codes: The list of expected status codes form the response.
Expand All @@ -978,11 +980,12 @@ def modify(

from scim2_models import User, PatchOp, PatchOperation

user = scim.query(User, "my-user-id")
operation = PatchOperation(
op="replace", path="displayName", value="New Display Name"
)
patch_op = PatchOp[User](operations=[operation])
response = scim.modify(User, "my-user-id", patch_op)
response = scim.modify(resource=user, patch_op=patch_op)
# 'response' may be a User, None, or an Error object

.. tip::
Expand Down Expand Up @@ -1193,18 +1196,19 @@ async def search(

async def delete(
self,
resource_model: type,
id: str,
resource: Resource,
check_response_payload: bool | None = None,
expected_status_codes: list[int]
| None = SCIMClient.DELETION_RESPONSE_STATUS_CODES,
raise_scim_errors: bool | None = None,
**kwargs,
) -> Error | dict | None:
"""Perform a DELETE request to create, as defined in :rfc:`RFC7644 §3.6 <7644#section-3.6>`.
"""Perform a DELETE request, as defined in :rfc:`RFC7644 §3.6 <7644#section-3.6>`.

:param resource_model: The type of the resource to delete.
:param id: The type id the resource to delete.
:param resource: The resource to delete. If the server supports ETags
and the resource has ``meta.version``, an ``If-Match`` header is sent.
:param resource_model: Deprecated. The type of the resource to delete.
:param id: Deprecated. The id of the resource to delete.
:param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
:param expected_status_codes: The list of expected status codes form the response.
If :data:`None` any status code is accepted.
Expand All @@ -1219,11 +1223,10 @@ async def delete(
:usage:

.. code-block:: python
:caption: Deleting an `User` which `id` is `foobar`

from scim2_models import User, SearchRequest
:caption: Deleting a User resource

response = scim.delete(User, "foobar")
user = scim.query(User, "foobar")
response = await scim.delete(resource=user)
# 'response' may be None, or an Error object
"""
raise NotImplementedError()
Expand Down Expand Up @@ -1276,8 +1279,7 @@ async def replace(

async def modify(
self,
resource_model: type[ResourceT],
id: str,
resource: ResourceT,
patch_op: PatchOp[ResourceT] | dict,
check_request_payload: bool | None = None,
check_response_payload: bool | None = None,
Expand All @@ -1288,11 +1290,11 @@ async def modify(
) -> ResourceT | Error | dict | None:
"""Perform a PATCH request to modify a resource, as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.

:param resource_model: The type of the resource to modify.
:param id: The id of the resource to modify.
:param resource: The resource to modify. If the server supports ETags
and the resource has ``meta.version``, an ``If-Match`` header is sent.
:param patch_op: The :class:`~scim2_models.PatchOp` object describing the modifications.
Must be parameterized with the same resource type as ``resource_model``
(e.g., :code:`PatchOp[User]` when ``resource_model`` is :code:`User`).
:param resource_model: Deprecated. The type of the resource to modify.
:param id: Deprecated. The id of the resource to modify.
:param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`.
:param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
:param expected_status_codes: The list of expected status codes form the response.
Expand All @@ -1313,11 +1315,12 @@ async def modify(

from scim2_models import User, PatchOp, PatchOperation

user = scim.query(User, "my-user-id")
operation = PatchOperation(
op="replace", path="displayName", value="New Display Name"
)
patch_op = PatchOp[User](operations=[operation])
response = await scim.modify(User, "my-user-id", patch_op)
response = await scim.modify(resource=user, patch_op=patch_op)
# 'response' may be a User, None, or an Error object

.. tip::
Expand Down
Loading
Loading