Skip to content

Commit cead335

Browse files
committed
feat: etags support
1 parent 5bd43bd commit cead335

10 files changed

Lines changed: 231 additions & 212 deletions

File tree

doc/changelog.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
Changelog
22
=========
33

4+
[0.7.6] - Unreleased
5+
--------------------
6+
7+
Added
8+
^^^^^
9+
- Support for ETags: ``replace``, ``modify`` and ``delete`` automatically send
10+
an ``If-Match`` header when the server advertises ETag support and the resource
11+
has a ``meta.version``. :issue:`47`
12+
13+
Breaking changes
14+
^^^^^^^^^^^^^^^^
15+
- ``delete`` now takes a resource instance instead of a resource type and id.
16+
:issue:`13`
17+
- ``modify`` now takes a resource instance and a patch operation instead of a
18+
resource type, id and patch operation. :issue:`13`
19+
420
[0.7.5] - 2026-04-02
521
--------------------
622

doc/tutorial.rst

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,8 @@ The :meth:`~scim2_client.BaseSyncSCIMClient.modify` method allows you to perform
124124
patch_op = PatchOp[User](operations=[operation])
125125
126126
# Apply the patch
127-
response = scim.modify(User, user_id, patch_op)
127+
user = scim.query(User, user_id)
128+
response = scim.modify(user, patch_op)
128129
if response: # Server returned 200 with updated resource
129130
print(f"User updated: {response.display_name}")
130131
else: # Server returned 204 (no content)
@@ -155,7 +156,7 @@ You can include multiple operations in a single PATCH request:
155156
)
156157
]
157158
patch_op = PatchOp[User](operations=operations)
158-
response = scim.modify(User, user_id, patch_op)
159+
response = scim.modify(user, patch_op)
159160
160161
Patch Operation Types
161162
~~~~~~~~~~~~~~~~~~~~~
@@ -201,6 +202,39 @@ To achieve this, all the methods provide the following parameters, all are :data
201202
which value will excluded from the request payload, and which values are
202203
expected in the response payload.
203204

205+
Resource versioning (ETags)
206+
==========================
207+
208+
SCIM supports resource versioning through HTTP ETags
209+
(:rfc:`RFC 7644 §3.14 <7644#section-3.14>`).
210+
When the server advertises ETag support in its
211+
:class:`~scim2_models.ServiceProviderConfig`, scim2-client automatically sends
212+
an ``If-Match`` header on write operations
213+
(:meth:`~scim2_client.BaseSyncSCIMClient.replace`,
214+
:meth:`~scim2_client.BaseSyncSCIMClient.modify`,
215+
:meth:`~scim2_client.BaseSyncSCIMClient.delete`)
216+
using the :attr:`meta.version <scim2_models.Meta.version>` value from the resource.
217+
218+
This enables optimistic concurrency control: the server will reject the request
219+
with ``412 Precondition Failed`` if the resource has been modified since it was
220+
last read.
221+
222+
.. code-block:: python
223+
224+
# Read a resource — meta.version is populated by the server
225+
user = scim.query(User, user_id)
226+
227+
# Modify it — If-Match is sent automatically
228+
user.display_name = "Updated Name"
229+
updated_user = scim.replace(user)
230+
231+
# Delete it — If-Match is sent automatically
232+
scim.delete(user)
233+
234+
No additional configuration is needed. If the server does not advertise ETag
235+
support, or if the resource has no :attr:`meta.version <scim2_models.Meta.version>`, no
236+
``If-Match`` header is sent.
237+
204238
Engines
205239
=======
206240

scim2_client/client.py

Lines changed: 59 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,19 @@ def __init__(
195195
self.check_response_status_codes = check_response_status_codes
196196
self.raise_scim_errors = raise_scim_errors
197197

198+
@property
199+
def _etag_supported(self) -> bool:
200+
spc = self.service_provider_config
201+
return bool(spc and spc.etag and spc.etag.supported)
202+
203+
def _set_if_match(self, req: RequestPayload, resource: Resource) -> None:
204+
"""Add ``If-Match`` header to the request if the server supports ETags."""
205+
if not self._etag_supported:
206+
return
207+
if resource.meta and resource.meta.version:
208+
headers = req.request_kwargs.setdefault("headers", {})
209+
headers.setdefault("If-Match", resource.meta.version)
210+
198211
def get_resource_model(self, name: str) -> type[Resource] | None:
199212
"""Get a registered model by its name or its schema."""
200213
for resource_model in self.resource_models:
@@ -515,8 +528,7 @@ def _prepare_search_request(
515528

516529
def _prepare_delete_request(
517530
self,
518-
resource_model: type[Resource],
519-
id: str,
531+
resource: Resource,
520532
expected_status_codes: list[int] | None = None,
521533
**kwargs,
522534
) -> RequestPayload:
@@ -525,9 +537,13 @@ def _prepare_delete_request(
525537
request_kwargs=kwargs,
526538
)
527539

540+
resource_model = type(resource)
528541
self._check_resource_model(resource_model)
529-
delete_url = self.resource_endpoint(resource_model) + f"/{id}"
542+
if not resource.id:
543+
raise SCIMRequestError("Resource must have an id", source=resource)
544+
delete_url = self.resource_endpoint(resource_model) + f"/{resource.id}"
530545
req.url = req.request_kwargs.pop("url", delete_url)
546+
self._set_if_match(req, resource)
531547
return req
532548

533549
def _prepare_replace_request(
@@ -575,6 +591,7 @@ def _prepare_replace_request(
575591
raise SCIMRequestError("Resource must have an id", source=resource)
576592

577593
req.expected_types = [resource.__class__]
594+
self._set_if_match(req, resource)
578595
req.payload = resource.model_dump(
579596
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST
580597
)
@@ -586,29 +603,15 @@ def _prepare_replace_request(
586603

587604
def _prepare_patch_request(
588605
self,
589-
resource_model: type[ResourceT],
590-
id: str,
591-
patch_op: PatchOp[ResourceT] | dict,
606+
resource: Resource,
607+
patch_op: PatchOp | dict,
592608
check_request_payload: bool | None = None,
593609
expected_status_codes: list[int] | None = None,
594610
**kwargs,
595611
) -> RequestPayload:
596-
"""Prepare a PATCH request payload.
597-
598-
:param resource_model: The resource type to modify (e.g., User, Group).
599-
:param id: The resource ID.
600-
:param patch_op: A PatchOp instance parameterized with the same resource type as resource_model
601-
(e.g., PatchOp[User] when resource_model is User), or a dict representation.
602-
:param check_request_payload: If :data:`False`, :code:`patch_op` is expected to be a dict
603-
that will be passed as-is in the request. This value can be
604-
overwritten in methods.
605-
:param expected_status_codes: List of HTTP status codes expected for this request.
606-
:param raise_scim_errors: If :data:`True` and the server returned an
607-
:class:`~scim2_models.Error` object during a request, a
608-
:class:`~scim2_client.SCIMResponseErrorObject` exception will be raised.
609-
:param kwargs: Additional request parameters.
610-
:return: The prepared request payload.
611-
"""
612+
"""Prepare a PATCH request payload."""
613+
resource_model = type(resource)
614+
id = resource.id
612615
req = RequestPayload(
613616
expected_status_codes=expected_status_codes,
614617
request_kwargs=kwargs,
@@ -644,12 +647,12 @@ def _prepare_patch_request(
644647
)
645648

646649
req.expected_types = [resource_model]
650+
self._set_if_match(req, resource)
647651
return req
648652

649653
def modify(
650654
self,
651-
resource_model: type[ResourceT],
652-
id: str,
655+
resource: ResourceT,
653656
patch_op: PatchOp[ResourceT] | dict,
654657
**kwargs,
655658
) -> ResourceT | Error | dict | None:
@@ -858,18 +861,19 @@ def search(
858861

859862
def delete(
860863
self,
861-
resource_model: type,
862-
id: str,
864+
resource: Resource,
863865
check_response_payload: bool | None = None,
864866
expected_status_codes: list[int]
865867
| None = SCIMClient.DELETION_RESPONSE_STATUS_CODES,
866868
raise_scim_errors: bool | None = None,
867869
**kwargs,
868870
) -> Error | dict | None:
869-
"""Perform a DELETE request to create, as defined in :rfc:`RFC7644 §3.6 <7644#section-3.6>`.
871+
"""Perform a DELETE request, as defined in :rfc:`RFC7644 §3.6 <7644#section-3.6>`.
870872
871-
:param resource_model: The type of the resource to delete.
872-
:param id: The type id the resource to delete.
873+
:param resource: The resource to delete. If the server supports ETags
874+
and the resource has ``meta.version``, an ``If-Match`` header is sent.
875+
:param resource_model: Deprecated. The type of the resource to delete.
876+
:param id: Deprecated. The id of the resource to delete.
873877
:param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
874878
:param expected_status_codes: The list of expected status codes form the response.
875879
If :data:`None` any status code is accepted.
@@ -884,11 +888,10 @@ def delete(
884888
:usage:
885889
886890
.. code-block:: python
887-
:caption: Deleting an `User` which `id` is `foobar`
891+
:caption: Deleting a User resource
888892
889-
from scim2_models import User, SearchRequest
890-
891-
response = scim.delete(User, "foobar")
893+
user = scim.query(User, "foobar")
894+
response = scim.delete(resource=user)
892895
# 'response' may be None, or an Error object
893896
"""
894897
raise NotImplementedError()
@@ -941,8 +944,7 @@ def replace(
941944

942945
def modify(
943946
self,
944-
resource_model: type[ResourceT],
945-
id: str,
947+
resource: ResourceT,
946948
patch_op: PatchOp[ResourceT] | dict,
947949
check_request_payload: bool | None = None,
948950
check_response_payload: bool | None = None,
@@ -953,11 +955,11 @@ def modify(
953955
) -> ResourceT | Error | dict | None:
954956
"""Perform a PATCH request to modify a resource, as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
955957
956-
:param resource_model: The type of the resource to modify.
957-
:param id: The id of the resource to modify.
958+
:param resource: The resource to modify. If the server supports ETags
959+
and the resource has ``meta.version``, an ``If-Match`` header is sent.
958960
:param patch_op: The :class:`~scim2_models.PatchOp` object describing the modifications.
959-
Must be parameterized with the same resource type as ``resource_model``
960-
(e.g., :code:`PatchOp[User]` when ``resource_model`` is :code:`User`).
961+
:param resource_model: Deprecated. The type of the resource to modify.
962+
:param id: Deprecated. The id of the resource to modify.
961963
:param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`.
962964
:param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
963965
:param expected_status_codes: The list of expected status codes form the response.
@@ -978,11 +980,12 @@ def modify(
978980
979981
from scim2_models import User, PatchOp, PatchOperation
980982
983+
user = scim.query(User, "my-user-id")
981984
operation = PatchOperation(
982985
op="replace", path="displayName", value="New Display Name"
983986
)
984987
patch_op = PatchOp[User](operations=[operation])
985-
response = scim.modify(User, "my-user-id", patch_op)
988+
response = scim.modify(resource=user, patch_op=patch_op)
986989
# 'response' may be a User, None, or an Error object
987990
988991
.. tip::
@@ -1193,18 +1196,19 @@ async def search(
11931196

11941197
async def delete(
11951198
self,
1196-
resource_model: type,
1197-
id: str,
1199+
resource: Resource,
11981200
check_response_payload: bool | None = None,
11991201
expected_status_codes: list[int]
12001202
| None = SCIMClient.DELETION_RESPONSE_STATUS_CODES,
12011203
raise_scim_errors: bool | None = None,
12021204
**kwargs,
12031205
) -> Error | dict | None:
1204-
"""Perform a DELETE request to create, as defined in :rfc:`RFC7644 §3.6 <7644#section-3.6>`.
1206+
"""Perform a DELETE request, as defined in :rfc:`RFC7644 §3.6 <7644#section-3.6>`.
12051207
1206-
:param resource_model: The type of the resource to delete.
1207-
:param id: The type id the resource to delete.
1208+
:param resource: The resource to delete. If the server supports ETags
1209+
and the resource has ``meta.version``, an ``If-Match`` header is sent.
1210+
:param resource_model: Deprecated. The type of the resource to delete.
1211+
:param id: Deprecated. The id of the resource to delete.
12081212
:param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
12091213
:param expected_status_codes: The list of expected status codes form the response.
12101214
If :data:`None` any status code is accepted.
@@ -1219,11 +1223,10 @@ async def delete(
12191223
:usage:
12201224
12211225
.. code-block:: python
1222-
:caption: Deleting an `User` which `id` is `foobar`
1223-
1224-
from scim2_models import User, SearchRequest
1226+
:caption: Deleting a User resource
12251227
1226-
response = scim.delete(User, "foobar")
1228+
user = scim.query(User, "foobar")
1229+
response = await scim.delete(resource=user)
12271230
# 'response' may be None, or an Error object
12281231
"""
12291232
raise NotImplementedError()
@@ -1276,8 +1279,7 @@ async def replace(
12761279

12771280
async def modify(
12781281
self,
1279-
resource_model: type[ResourceT],
1280-
id: str,
1282+
resource: ResourceT,
12811283
patch_op: PatchOp[ResourceT] | dict,
12821284
check_request_payload: bool | None = None,
12831285
check_response_payload: bool | None = None,
@@ -1288,11 +1290,11 @@ async def modify(
12881290
) -> ResourceT | Error | dict | None:
12891291
"""Perform a PATCH request to modify a resource, as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
12901292
1291-
:param resource_model: The type of the resource to modify.
1292-
:param id: The id of the resource to modify.
1293+
:param resource: The resource to modify. If the server supports ETags
1294+
and the resource has ``meta.version``, an ``If-Match`` header is sent.
12931295
:param patch_op: The :class:`~scim2_models.PatchOp` object describing the modifications.
1294-
Must be parameterized with the same resource type as ``resource_model``
1295-
(e.g., :code:`PatchOp[User]` when ``resource_model`` is :code:`User`).
1296+
:param resource_model: Deprecated. The type of the resource to modify.
1297+
:param id: Deprecated. The id of the resource to modify.
12961298
:param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`.
12971299
:param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
12981300
:param expected_status_codes: The list of expected status codes form the response.
@@ -1313,11 +1315,12 @@ async def modify(
13131315
13141316
from scim2_models import User, PatchOp, PatchOperation
13151317
1318+
user = scim.query(User, "my-user-id")
13161319
operation = PatchOperation(
13171320
op="replace", path="displayName", value="New Display Name"
13181321
)
13191322
patch_op = PatchOp[User](operations=[operation])
1320-
response = await scim.modify(User, "my-user-id", patch_op)
1323+
response = await scim.modify(resource=user, patch_op=patch_op)
13211324
# 'response' may be a User, None, or an Error object
13221325
13231326
.. tip::

0 commit comments

Comments
 (0)