Skip to content

Commit 0318ee7

Browse files
committed
feat: conditional query with If-None-Match
1 parent cead335 commit 0318ee7

11 files changed

Lines changed: 370 additions & 72 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@ Added
99
- Support for ETags: ``replace``, ``modify`` and ``delete`` automatically send
1010
an ``If-Match`` header when the server advertises ETag support and the resource
1111
has a ``meta.version``. :issue:`47`
12+
- ``query`` sends an ``If-None-Match`` header when passed a resource instance
13+
with ``meta.version`` and the server supports ETags. On ``304 Not Modified``
14+
the original instance is returned. :issue:`47`
1215

1316
Breaking changes
1417
^^^^^^^^^^^^^^^^
18+
- ``query`` now takes a resource type, a resource instance, or ``None`` instead
19+
of a resource type and id. :issue:`13`
1520
- ``delete`` now takes a resource instance instead of a resource type and id.
1621
:issue:`13`
1722
- ``modify`` now takes a resource instance and a patch operation instead of a

‎doc/tutorial.rst‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ The :meth:`~scim2_client.BaseSyncSCIMClient.modify` method allows you to perform
124124
patch_op = PatchOp[User](operations=[operation])
125125
126126
# Apply the patch
127-
user = scim.query(User, user_id)
127+
user = scim.query(User(id=user_id))
128128
response = scim.modify(user, patch_op)
129129
if response: # Server returned 200 with updated resource
130130
print(f"User updated: {response.display_name}")
@@ -219,10 +219,18 @@ This enables optimistic concurrency control: the server will reject the request
219219
with ``412 Precondition Failed`` if the resource has been modified since it was
220220
last read.
221221

222+
For read operations, :meth:`~scim2_client.BaseSyncSCIMClient.query` sends an
223+
``If-None-Match`` header when passed a resource instance with
224+
:attr:`meta.version <scim2_models.Meta.version>`. If the server responds with
225+
``304 Not Modified``, the original instance is returned without parsing.
226+
222227
.. code-block:: python
223228
224229
# Read a resource — meta.version is populated by the server
225-
user = scim.query(User, user_id)
230+
user = scim.query(User(id=user_id))
231+
232+
# Re-read — If-None-Match is sent; returns the same object on 304
233+
user = scim.query(user)
226234
227235
# Modify it — If-Match is sent automatically
228236
user.display_name = "Updated Name"
@@ -233,7 +241,7 @@ last read.
233241
234242
No additional configuration is needed. If the server does not advertise ETag
235243
support, or if the resource has no :attr:`meta.version <scim2_models.Meta.version>`, no
236-
``If-Match`` header is sent.
244+
conditional header is sent.
237245

238246
Engines
239247
=======

‎scim2_client/client.py‎

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class RequestPayload:
4545
payload: dict | None = None
4646
expected_types: list[type[Resource]] | None = None
4747
expected_status_codes: list[int] | None = None
48+
target: Resource | None = None
4849

4950

5051
class SCIMClient:
@@ -92,10 +93,21 @@ class SCIMClient:
9293
:rfc:`RFC7644 §3.12 <7644#section-3.12>`.
9394
"""
9495

95-
QUERY_RESPONSE_STATUS_CODES: list[int] = [200, 400, 307, 308, 401, 403, 404, 500]
96+
QUERY_RESPONSE_STATUS_CODES: list[int] = [
97+
200,
98+
304,
99+
400,
100+
307,
101+
308,
102+
401,
103+
403,
104+
404,
105+
500,
106+
]
96107
"""Resource querying HTTP codes.
97108
98-
As defined at :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>` and
109+
As defined at :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>`,
110+
:rfc:`RFC7644 §3.14 <7644#section-3.14>` and
99111
:rfc:`RFC7644 §3.12 <7644#section-3.12>`.
100112
"""
101113

@@ -208,6 +220,14 @@ def _set_if_match(self, req: RequestPayload, resource: Resource) -> None:
208220
headers = req.request_kwargs.setdefault("headers", {})
209221
headers.setdefault("If-Match", resource.meta.version)
210222

223+
def _set_if_none_match(self, req: RequestPayload, resource: Resource) -> None:
224+
"""Add ``If-None-Match`` header to the request if the server supports ETags."""
225+
if not self._etag_supported:
226+
return
227+
if resource.meta and resource.meta.version:
228+
headers = req.request_kwargs.setdefault("headers", {})
229+
headers.setdefault("If-None-Match", resource.meta.version)
230+
211231
def get_resource_model(self, name: str) -> type[Resource] | None:
212232
"""Get a registered model by its name or its schema."""
213233
for resource_model in self.resource_models:
@@ -301,10 +321,14 @@ def check_response(
301321
check_response_payload: bool | None = None,
302322
raise_scim_errors: bool | None = None,
303323
scim_ctx: Context | None = None,
324+
target: Resource | None = None,
304325
) -> Error | None | dict | type[Resource]:
305326
if raise_scim_errors is None:
306327
raise_scim_errors = self.raise_scim_errors
307328

329+
if status_code == 304 and target:
330+
return target
331+
308332
# In addition to returning an HTTP response code, implementers MUST return
309333
# the errors in the body of the response in a JSON format
310334
# https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
@@ -434,8 +458,7 @@ def _resolve_query_parameters(
434458

435459
def _prepare_query_request(
436460
self,
437-
resource_model: type[Resource] | None = None,
438-
id: str | None = None,
461+
target: type[Resource] | Resource | None = None,
439462
query_parameters: ResponseParameters | dict | None = None,
440463
check_request_payload: bool | None = None,
441464
expected_status_codes: list[int] | None = None,
@@ -446,6 +469,14 @@ def _prepare_query_request(
446469
request_kwargs=kwargs,
447470
)
448471

472+
resource_model: type[Resource] | None
473+
if isinstance(target, Resource):
474+
resource_model = type(target)
475+
id = target.id
476+
else:
477+
resource_model = target
478+
id = None
479+
449480
if check_request_payload is None:
450481
check_request_payload = self.check_request_payload
451482

@@ -489,6 +520,9 @@ def _prepare_query_request(
489520
elif id:
490521
req.expected_types = [resource_model]
491522
req.url = f"{req.url}/{id}"
523+
if isinstance(target, Resource) and not payload:
524+
req.target = target
525+
self._set_if_none_match(req, target)
492526

493527
else:
494528
req.expected_types = [ListResponse[resource_model]]
@@ -733,8 +767,7 @@ def create(
733767

734768
def query(
735769
self,
736-
resource_model: type[Resource] | None = None,
737-
id: str | None = None,
770+
target: type[Resource] | Resource | None = None,
738771
query_parameters: ResponseParameters | dict | None = None,
739772
check_request_payload: bool | None = None,
740773
check_response_payload: bool | None = None,
@@ -746,11 +779,15 @@ def query(
746779
) -> Resource | ListResponse[Resource] | Error | dict:
747780
"""Perform a GET request to read resources, as defined in :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>`.
748781
749-
- If `id` is not :data:`None`, the resource with the exact id will be reached.
750-
- If `id` is :data:`None`, all the resources with the given type will be reached.
782+
- If ``target`` is a :class:`~scim2_models.Resource` instance, the resource
783+
with the same id will be queried. If the server supports ETags and the
784+
resource has ``meta.version``, an ``If-None-Match`` header is sent;
785+
on ``304 Not Modified`` the original instance is returned.
786+
- If ``target`` is a :class:`~scim2_models.Resource` subtype, all the
787+
resources with the given type will be reached.
788+
- If ``target`` is :data:`None`, all available resources will be reached.
751789
752-
:param resource_model: A :class:`~scim2_models.Resource` subtype or :data:`None`
753-
:param id: The SCIM id of an object to get, or :data:`None`
790+
:param target: A :class:`~scim2_models.Resource` instance, subtype, or :data:`None`.
754791
:param query_parameters: A :class:`~scim2_models.ResponseParameters` or
755792
:class:`~scim2_models.SearchRequest` detailing the query parameters.
756793
Use :class:`~scim2_models.ResponseParameters` when querying a single
@@ -768,8 +805,9 @@ def query(
768805
769806
:return:
770807
- A :class:`~scim2_models.Error` object in case of error.
771-
- A `resource_model` object in case of success if `id` is not :data:`None`
772-
- A :class:`~scim2_models.ListResponse[resource_model]` object in case of success if `id` is :data:`None`
808+
- The original ``target`` instance if the server responds with ``304 Not Modified``.
809+
- A ``target`` type object in case of success if ``target`` is a resource instance.
810+
- A :class:`~scim2_models.ListResponse` object in case of success if ``target`` is a resource type.
773811
774812
.. note::
775813
@@ -783,7 +821,7 @@ def query(
783821
784822
from scim2_models import User
785823
786-
response = scim.query(User, "my-user-id)
824+
response = scim.query(User(id="my-user-id"))
787825
# 'response' may be a User or an Error object
788826
789827
.. code-block:: python
@@ -1068,8 +1106,7 @@ async def create(
10681106

10691107
async def query(
10701108
self,
1071-
resource_model: type[Resource] | None = None,
1072-
id: str | None = None,
1109+
target: type[Resource] | Resource | None = None,
10731110
query_parameters: ResponseParameters | dict | None = None,
10741111
check_request_payload: bool | None = None,
10751112
check_response_payload: bool | None = None,
@@ -1081,11 +1118,15 @@ async def query(
10811118
) -> Resource | ListResponse[Resource] | Error | dict:
10821119
"""Perform a GET request to read resources, as defined in :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>`.
10831120
1084-
- If `id` is not :data:`None`, the resource with the exact id will be reached.
1085-
- If `id` is :data:`None`, all the resources with the given type will be reached.
1121+
- If ``target`` is a :class:`~scim2_models.Resource` instance, the resource
1122+
with the same id will be queried. If the server supports ETags and the
1123+
resource has ``meta.version``, an ``If-None-Match`` header is sent;
1124+
on ``304 Not Modified`` the original instance is returned.
1125+
- If ``target`` is a :class:`~scim2_models.Resource` subtype, all the
1126+
resources with the given type will be reached.
1127+
- If ``target`` is :data:`None`, all available resources will be reached.
10861128
1087-
:param resource_model: A :class:`~scim2_models.Resource` subtype or :data:`None`
1088-
:param id: The SCIM id of an object to get, or :data:`None`
1129+
:param target: A :class:`~scim2_models.Resource` instance, subtype, or :data:`None`.
10891130
:param query_parameters: A :class:`~scim2_models.ResponseParameters` or
10901131
:class:`~scim2_models.SearchRequest` detailing the query parameters.
10911132
Use :class:`~scim2_models.ResponseParameters` when querying a single
@@ -1103,8 +1144,9 @@ async def query(
11031144
11041145
:return:
11051146
- A :class:`~scim2_models.Error` object in case of error.
1106-
- A `resource_model` object in case of success if `id` is not :data:`None`
1107-
- A :class:`~scim2_models.ListResponse[resource_model]` object in case of success if `id` is :data:`None`
1147+
- The original ``target`` instance if the server responds with ``304 Not Modified``.
1148+
- A ``target`` type object in case of success if ``target`` is a resource instance.
1149+
- A :class:`~scim2_models.ListResponse` object in case of success if ``target`` is a resource type.
11081150
11091151
.. note::
11101152
@@ -1118,7 +1160,7 @@ async def query(
11181160
11191161
from scim2_models import User
11201162
1121-
response = scim.query(User, "my-user-id)
1163+
response = await scim.query(User(id="my-user-id"))
11221164
# 'response' may be a User or an Error object
11231165
11241166
.. code-block:: python
@@ -1127,15 +1169,15 @@ async def query(
11271169
from scim2_models import User, SearchRequest
11281170
11291171
req = SearchRequest(filter='userName sw "john"')
1130-
response = scim.query(User, query_parameters=req)
1172+
response = await scim.query(User, query_parameters=req)
11311173
# 'response' may be a ListResponse[User] or an Error object
11321174
11331175
.. code-block:: python
11341176
:caption: Query of all the available resources
11351177
11361178
from scim2_models import User
11371179
1138-
response = scim.query()
1180+
response = await scim.query()
11391181
# 'response' may be a ListResponse[Union[User, Group, ...]] or an Error object
11401182
11411183
.. tip::

‎scim2_client/engines/httpx.py‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,7 @@ def create(
104104

105105
def query(
106106
self,
107-
resource_model: type[Resource] | None = None,
108-
id: str | None = None,
107+
target: type[Resource] | Resource | None = None,
109108
query_parameters: ResponseParameters | dict | None = None,
110109
check_request_payload: bool | None = None,
111110
check_response_payload: bool | None = None,
@@ -119,8 +118,7 @@ def query(
119118
query_parameters, search_request
120119
)
121120
req = self._prepare_query_request(
122-
resource_model=resource_model,
123-
id=id,
121+
target=target,
124122
query_parameters=query_parameters,
125123
check_request_payload=check_request_payload,
126124
expected_status_codes=expected_status_codes,
@@ -142,6 +140,7 @@ def query(
142140
check_response_payload=check_response_payload,
143141
raise_scim_errors=raise_scim_errors,
144142
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
143+
target=req.target,
145144
)
146145

147146
def search(
@@ -330,8 +329,7 @@ async def create(
330329

331330
async def query(
332331
self,
333-
resource_model: type[Resource] | None = None,
334-
id: str | None = None,
332+
target: type[Resource] | Resource | None = None,
335333
query_parameters: ResponseParameters | dict | None = None,
336334
check_request_payload: bool | None = None,
337335
check_response_payload: bool | None = None,
@@ -345,8 +343,7 @@ async def query(
345343
query_parameters, search_request
346344
)
347345
req = self._prepare_query_request(
348-
resource_model=resource_model,
349-
id=id,
346+
target=target,
350347
query_parameters=query_parameters,
351348
check_request_payload=check_request_payload,
352349
expected_status_codes=expected_status_codes,
@@ -368,6 +365,7 @@ async def query(
368365
check_response_payload=check_response_payload,
369366
raise_scim_errors=raise_scim_errors,
370367
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
368+
target=req.target,
371369
)
372370

373371
async def search(

‎scim2_client/engines/werkzeug.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,7 @@ def create(
138138

139139
def query(
140140
self,
141-
resource_model: type[Resource] | None = None,
142-
id: str | None = None,
141+
target: type[Resource] | Resource | None = None,
143142
query_parameters: ResponseParameters | dict | None = None,
144143
check_request_payload: bool | None = None,
145144
check_response_payload: bool | None = None,
@@ -153,8 +152,7 @@ def query(
153152
query_parameters, search_request
154153
)
155154
req = self._prepare_query_request(
156-
resource_model=resource_model,
157-
id=id,
155+
target=target,
158156
query_parameters=query_parameters,
159157
check_request_payload=check_request_payload,
160158
expected_status_codes=expected_status_codes,
@@ -177,6 +175,7 @@ def query(
177175
check_response_payload=check_response_payload,
178176
raise_scim_errors=raise_scim_errors,
179177
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
178+
target=req.target,
180179
)
181180

182181
def search(

0 commit comments

Comments
 (0)