diff --git a/doc/changelog.rst b/doc/changelog.rst index dc18d1c..baca6ca 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,27 @@ 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` +- ``query`` sends an ``If-None-Match`` header when passed a resource instance + with ``meta.version`` and the server supports ETags. On ``304 Not Modified`` + the original instance is returned. :issue:`47` + +Breaking changes +^^^^^^^^^^^^^^^^ +- ``query`` now takes a resource type, a resource instance, or ``None`` instead + of a resource type and id. :issue:`13` +- ``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 -------------------- diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 31d8884..a0063e1 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -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(id=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) @@ -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 ~~~~~~~~~~~~~~~~~~~~~ @@ -201,6 +202,47 @@ 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 ` 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. + +For read operations, :meth:`~scim2_client.BaseSyncSCIMClient.query` sends an +``If-None-Match`` header when passed a resource instance with +:attr:`meta.version `. If the server responds with +``304 Not Modified``, the original instance is returned without parsing. + +.. code-block:: python + + # Read a resource — meta.version is populated by the server + user = scim.query(User(id=user_id)) + + # Re-read — If-None-Match is sent; returns the same object on 304 + user = scim.query(user) + + # 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 `, no +conditional header is sent. + Engines ======= diff --git a/scim2_client/client.py b/scim2_client/client.py index 76bb2be..a6267da 100644 --- a/scim2_client/client.py +++ b/scim2_client/client.py @@ -45,6 +45,7 @@ class RequestPayload: payload: dict | None = None expected_types: list[type[Resource]] | None = None expected_status_codes: list[int] | None = None + target: Resource | None = None class SCIMClient: @@ -92,10 +93,21 @@ class SCIMClient: :rfc:`RFC7644 §3.12 <7644#section-3.12>`. """ - QUERY_RESPONSE_STATUS_CODES: list[int] = [200, 400, 307, 308, 401, 403, 404, 500] + QUERY_RESPONSE_STATUS_CODES: list[int] = [ + 200, + 304, + 400, + 307, + 308, + 401, + 403, + 404, + 500, + ] """Resource querying HTTP codes. - As defined at :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>` and + As defined at :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>`, + :rfc:`RFC7644 §3.14 <7644#section-3.14>` and :rfc:`RFC7644 §3.12 <7644#section-3.12>`. """ @@ -195,6 +207,27 @@ 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 _set_if_none_match(self, req: RequestPayload, resource: Resource) -> None: + """Add ``If-None-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-None-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: @@ -288,10 +321,14 @@ def check_response( check_response_payload: bool | None = None, raise_scim_errors: bool | None = None, scim_ctx: Context | None = None, + target: Resource | None = None, ) -> Error | None | dict | type[Resource]: if raise_scim_errors is None: raise_scim_errors = self.raise_scim_errors + if status_code == 304 and target: + return target + # In addition to returning an HTTP response code, implementers MUST return # the errors in the body of the response in a JSON format # https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12 @@ -421,8 +458,7 @@ def _resolve_query_parameters( def _prepare_query_request( self, - resource_model: type[Resource] | None = None, - id: str | None = None, + target: type[Resource] | Resource | None = None, query_parameters: ResponseParameters | dict | None = None, check_request_payload: bool | None = None, expected_status_codes: list[int] | None = None, @@ -433,6 +469,14 @@ def _prepare_query_request( request_kwargs=kwargs, ) + resource_model: type[Resource] | None + if isinstance(target, Resource): + resource_model = type(target) + id = target.id + else: + resource_model = target + id = None + if check_request_payload is None: check_request_payload = self.check_request_payload @@ -476,6 +520,9 @@ def _prepare_query_request( elif id: req.expected_types = [resource_model] req.url = f"{req.url}/{id}" + if isinstance(target, Resource) and not payload: + req.target = target + self._set_if_none_match(req, target) else: req.expected_types = [ListResponse[resource_model]] @@ -515,8 +562,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: @@ -525,9 +571,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( @@ -575,6 +625,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 ) @@ -586,29 +637,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, @@ -644,12 +681,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: @@ -730,8 +767,7 @@ def create( def query( self, - resource_model: type[Resource] | None = None, - id: str | None = None, + target: type[Resource] | Resource | None = None, query_parameters: ResponseParameters | dict | None = None, check_request_payload: bool | None = None, check_response_payload: bool | None = None, @@ -743,11 +779,15 @@ def query( ) -> Resource | ListResponse[Resource] | Error | dict: """Perform a GET request to read resources, as defined in :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>`. - - If `id` is not :data:`None`, the resource with the exact id will be reached. - - If `id` is :data:`None`, all the resources with the given type will be reached. + - If ``target`` is a :class:`~scim2_models.Resource` instance, the resource + with the same id will be queried. If the server supports ETags and the + resource has ``meta.version``, an ``If-None-Match`` header is sent; + on ``304 Not Modified`` the original instance is returned. + - If ``target`` is a :class:`~scim2_models.Resource` subtype, all the + resources with the given type will be reached. + - If ``target`` is :data:`None`, all available resources will be reached. - :param resource_model: A :class:`~scim2_models.Resource` subtype or :data:`None` - :param id: The SCIM id of an object to get, or :data:`None` + :param target: A :class:`~scim2_models.Resource` instance, subtype, or :data:`None`. :param query_parameters: A :class:`~scim2_models.ResponseParameters` or :class:`~scim2_models.SearchRequest` detailing the query parameters. Use :class:`~scim2_models.ResponseParameters` when querying a single @@ -765,8 +805,9 @@ def query( :return: - A :class:`~scim2_models.Error` object in case of error. - - A `resource_model` object in case of success if `id` is not :data:`None` - - A :class:`~scim2_models.ListResponse[resource_model]` object in case of success if `id` is :data:`None` + - The original ``target`` instance if the server responds with ``304 Not Modified``. + - A ``target`` type object in case of success if ``target`` is a resource instance. + - A :class:`~scim2_models.ListResponse` object in case of success if ``target`` is a resource type. .. note:: @@ -780,7 +821,7 @@ def query( from scim2_models import User - response = scim.query(User, "my-user-id) + response = scim.query(User(id="my-user-id")) # 'response' may be a User or an Error object .. code-block:: python @@ -858,18 +899,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. @@ -884,11 +926,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() @@ -941,8 +982,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, @@ -953,11 +993,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. @@ -978,11 +1018,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:: @@ -1065,8 +1106,7 @@ async def create( async def query( self, - resource_model: type[Resource] | None = None, - id: str | None = None, + target: type[Resource] | Resource | None = None, query_parameters: ResponseParameters | dict | None = None, check_request_payload: bool | None = None, check_response_payload: bool | None = None, @@ -1078,11 +1118,15 @@ async def query( ) -> Resource | ListResponse[Resource] | Error | dict: """Perform a GET request to read resources, as defined in :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>`. - - If `id` is not :data:`None`, the resource with the exact id will be reached. - - If `id` is :data:`None`, all the resources with the given type will be reached. + - If ``target`` is a :class:`~scim2_models.Resource` instance, the resource + with the same id will be queried. If the server supports ETags and the + resource has ``meta.version``, an ``If-None-Match`` header is sent; + on ``304 Not Modified`` the original instance is returned. + - If ``target`` is a :class:`~scim2_models.Resource` subtype, all the + resources with the given type will be reached. + - If ``target`` is :data:`None`, all available resources will be reached. - :param resource_model: A :class:`~scim2_models.Resource` subtype or :data:`None` - :param id: The SCIM id of an object to get, or :data:`None` + :param target: A :class:`~scim2_models.Resource` instance, subtype, or :data:`None`. :param query_parameters: A :class:`~scim2_models.ResponseParameters` or :class:`~scim2_models.SearchRequest` detailing the query parameters. Use :class:`~scim2_models.ResponseParameters` when querying a single @@ -1100,8 +1144,9 @@ async def query( :return: - A :class:`~scim2_models.Error` object in case of error. - - A `resource_model` object in case of success if `id` is not :data:`None` - - A :class:`~scim2_models.ListResponse[resource_model]` object in case of success if `id` is :data:`None` + - The original ``target`` instance if the server responds with ``304 Not Modified``. + - A ``target`` type object in case of success if ``target`` is a resource instance. + - A :class:`~scim2_models.ListResponse` object in case of success if ``target`` is a resource type. .. note:: @@ -1115,7 +1160,7 @@ async def query( from scim2_models import User - response = scim.query(User, "my-user-id) + response = await scim.query(User(id="my-user-id")) # 'response' may be a User or an Error object .. code-block:: python @@ -1124,7 +1169,7 @@ async def query( from scim2_models import User, SearchRequest req = SearchRequest(filter='userName sw "john"') - response = scim.query(User, query_parameters=req) + response = await scim.query(User, query_parameters=req) # 'response' may be a ListResponse[User] or an Error object .. code-block:: python @@ -1132,7 +1177,7 @@ async def query( from scim2_models import User - response = scim.query() + response = await scim.query() # 'response' may be a ListResponse[Union[User, Group, ...]] or an Error object .. tip:: @@ -1193,18 +1238,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. @@ -1219,11 +1265,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() @@ -1276,8 +1321,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, @@ -1288,11 +1332,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. @@ -1313,11 +1357,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:: diff --git a/scim2_client/engines/httpx.py b/scim2_client/engines/httpx.py index 874c428..fe65a2e 100644 --- a/scim2_client/engines/httpx.py +++ b/scim2_client/engines/httpx.py @@ -104,8 +104,7 @@ def create( def query( self, - resource_model: type[Resource] | None = None, - id: str | None = None, + target: type[Resource] | Resource | None = None, query_parameters: ResponseParameters | dict | None = None, check_request_payload: bool | None = None, check_response_payload: bool | None = None, @@ -119,8 +118,7 @@ def query( query_parameters, search_request ) req = self._prepare_query_request( - resource_model=resource_model, - id=id, + target=target, query_parameters=query_parameters, check_request_payload=check_request_payload, expected_status_codes=expected_status_codes, @@ -142,6 +140,7 @@ def query( check_response_payload=check_response_payload, raise_scim_errors=raise_scim_errors, scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + target=req.target, ) def search( @@ -178,8 +177,7 @@ def search( def delete( self, - resource_model: type[Resource], - id: str, + resource: Resource, check_response_payload: bool | None = None, expected_status_codes: list[int] | None = BaseSyncSCIMClient.DELETION_RESPONSE_STATUS_CODES, @@ -187,8 +185,7 @@ def delete( **kwargs, ) -> Error | dict | None: req = self._prepare_delete_request( - resource_model=resource_model, - id=id, + resource=resource, expected_status_codes=expected_status_codes, **kwargs, ) @@ -240,8 +237,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, @@ -251,8 +247,7 @@ def modify( **kwargs, ) -> ResourceT | Error | dict | None: req = self._prepare_patch_request( - resource_model=resource_model, - id=id, + resource=resource, patch_op=patch_op, check_request_payload=check_request_payload, expected_status_codes=expected_status_codes, @@ -334,8 +329,7 @@ async def create( async def query( self, - resource_model: type[Resource] | None = None, - id: str | None = None, + target: type[Resource] | Resource | None = None, query_parameters: ResponseParameters | dict | None = None, check_request_payload: bool | None = None, check_response_payload: bool | None = None, @@ -349,8 +343,7 @@ async def query( query_parameters, search_request ) req = self._prepare_query_request( - resource_model=resource_model, - id=id, + target=target, query_parameters=query_parameters, check_request_payload=check_request_payload, expected_status_codes=expected_status_codes, @@ -372,6 +365,7 @@ async def query( check_response_payload=check_response_payload, raise_scim_errors=raise_scim_errors, scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + target=req.target, ) async def search( @@ -410,8 +404,7 @@ async def search( async def delete( self, - resource_model: type[Resource], - id: str, + resource: Resource, check_response_payload: bool | None = None, expected_status_codes: list[int] | None = BaseAsyncSCIMClient.DELETION_RESPONSE_STATUS_CODES, @@ -419,8 +412,7 @@ async def delete( **kwargs, ) -> Error | dict | None: req = self._prepare_delete_request( - resource_model=resource_model, - id=id, + resource=resource, expected_status_codes=expected_status_codes, **kwargs, ) @@ -474,8 +466,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, @@ -485,8 +476,7 @@ async def modify( **kwargs, ) -> ResourceT | Error | dict | None: req = self._prepare_patch_request( - resource_model=resource_model, - id=id, + resource=resource, patch_op=patch_op, check_request_payload=check_request_payload, expected_status_codes=expected_status_codes, diff --git a/scim2_client/engines/werkzeug.py b/scim2_client/engines/werkzeug.py index 3d4dad6..f6e3574 100644 --- a/scim2_client/engines/werkzeug.py +++ b/scim2_client/engines/werkzeug.py @@ -138,8 +138,7 @@ def create( def query( self, - resource_model: type[Resource] | None = None, - id: str | None = None, + target: type[Resource] | Resource | None = None, query_parameters: ResponseParameters | dict | None = None, check_request_payload: bool | None = None, check_response_payload: bool | None = None, @@ -153,8 +152,7 @@ def query( query_parameters, search_request ) req = self._prepare_query_request( - resource_model=resource_model, - id=id, + target=target, query_parameters=query_parameters, check_request_payload=check_request_payload, expected_status_codes=expected_status_codes, @@ -177,6 +175,7 @@ def query( check_response_payload=check_response_payload, raise_scim_errors=raise_scim_errors, scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + target=req.target, ) def search( @@ -215,8 +214,7 @@ def search( def delete( self, - resource_model: type[Resource], - id: str, + resource: Resource, check_response_payload: bool | None = None, expected_status_codes: list[int] | None = BaseSyncSCIMClient.DELETION_RESPONSE_STATUS_CODES, @@ -224,8 +222,7 @@ def delete( **kwargs, ) -> Error | dict | None: req = self._prepare_delete_request( - resource_model=resource_model, - id=id, + resource=resource, expected_status_codes=expected_status_codes, **kwargs, ) @@ -277,8 +274,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, @@ -288,8 +284,7 @@ def modify( **kwargs, ) -> ResourceT | Error | dict | None: req = self._prepare_patch_request( - resource_model=resource_model, - id=id, + resource=resource, patch_op=patch_op, check_request_payload=check_request_payload, expected_status_codes=expected_status_codes, diff --git a/tests/conftest.py b/tests/conftest.py index ebcbab7..2f3af09 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +import uuid + import pytest from httpx import Client from scim2_models import Group @@ -15,3 +17,17 @@ def sync_client(httpserver): ) scim_client.register_naive_resource_types() yield scim_client + + +@pytest.fixture +def user(): + u = User(user_name="bjensen@example.com") + u.id = str(uuid.uuid4()) + return u + + +@pytest.fixture +def group(): + g = Group(display_name="Tour Guides") + g.id = str(uuid.uuid4()) + return g diff --git a/tests/engines/test_httpx.py b/tests/engines/test_httpx.py index 8d001a7..cb79063 100644 --- a/tests/engines/test_httpx.py +++ b/tests/engines/test_httpx.py @@ -63,7 +63,7 @@ def test_sync_engine(server): assert response_user.user_name == "foo" assert response_user.display_name == "bar" - response_user = scim_client.query(User, response_user.id) + response_user = scim_client.query(response_user) assert response_user.user_name == "foo" assert response_user.display_name == "bar" @@ -77,7 +77,7 @@ def test_sync_engine(server): assert response_user.user_name == "foo" assert response_user.display_name == "baz" - response_user = scim_client.query(User, response_user.id) + response_user = scim_client.query(response_user) assert response_user.user_name == "foo" assert response_user.display_name == "baz" @@ -86,15 +86,15 @@ def test_sync_engine(server): op=PatchOperation.Op.replace_, path="displayName", value="patched name" ) patch_op = PatchOp[User](operations=[operation]) - scim_client.modify(User, response_user.id, patch_op) + scim_client.modify(response_user, patch_op) # Verify patch result with query - queried_user = scim_client.query(User, response_user.id) + queried_user = scim_client.query(response_user) assert queried_user.display_name == "patched name" - scim_client.delete(User, response_user.id) + scim_client.delete(response_user) with pytest.raises(SCIMResponseErrorObject): - scim_client.query(User, response_user.id) + scim_client.query(response_user) async def test_async_engine(server): @@ -118,7 +118,7 @@ async def test_async_engine(server): assert response_user.user_name == "async_foo" assert response_user.display_name == "async_bar" - response_user = await scim_client.query(User, response_user.id) + response_user = await scim_client.query(response_user) assert response_user.user_name == "async_foo" assert response_user.display_name == "async_bar" @@ -136,7 +136,7 @@ async def test_async_engine(server): assert response_user.user_name == "async_foo" assert response_user.display_name == "async_baz" - response_user = await scim_client.query(User, response_user.id) + response_user = await scim_client.query(response_user) assert response_user.user_name == "async_foo" assert response_user.display_name == "async_baz" @@ -145,12 +145,12 @@ async def test_async_engine(server): op=PatchOperation.Op.replace_, path="displayName", value="async patched name" ) patch_op = PatchOp[User](operations=[operation]) - await scim_client.modify(User, response_user.id, patch_op) + await scim_client.modify(response_user, patch_op) # Verify patch result with query - queried_user = await scim_client.query(User, response_user.id) + queried_user = await scim_client.query(response_user) assert queried_user.display_name == "async patched name" - await scim_client.delete(User, response_user.id) + await scim_client.delete(response_user) with pytest.raises(SCIMResponseErrorObject): - await scim_client.query(User, response_user.id) + await scim_client.query(response_user) diff --git a/tests/engines/test_werkzeug.py b/tests/engines/test_werkzeug.py index 1cd07bb..5eac104 100644 --- a/tests/engines/test_werkzeug.py +++ b/tests/engines/test_werkzeug.py @@ -44,7 +44,7 @@ def test_werkzeug_engine(scim_client): assert response_user.user_name == "foo" assert response_user.display_name == "bar" - response_user = scim_client.query(User, response_user.id) + response_user = scim_client.query(response_user) assert response_user.user_name == "foo" assert response_user.display_name == "bar" @@ -58,7 +58,7 @@ def test_werkzeug_engine(scim_client): assert response_user.user_name == "foo" assert response_user.display_name == "baz" - response_user = scim_client.query(User, response_user.id) + response_user = scim_client.query(response_user) assert response_user.user_name == "foo" assert response_user.display_name == "baz" @@ -67,15 +67,15 @@ def test_werkzeug_engine(scim_client): op=PatchOperation.Op.replace_, path="displayName", value="werkzeug patched" ) patch_op = PatchOp[User](operations=[operation]) - scim_client.modify(User, response_user.id, patch_op) + scim_client.modify(response_user, patch_op) # Verify patch result with query - queried_user = scim_client.query(User, response_user.id) + queried_user = scim_client.query(response_user) assert queried_user.display_name == "werkzeug patched" - scim_client.delete(User, response_user.id) + scim_client.delete(response_user) with pytest.raises(SCIMResponseErrorObject): - scim_client.query(User, response_user.id) + scim_client.query(response_user) def test_werkzeug_query_with_attributes(scim_client): @@ -85,7 +85,7 @@ def test_werkzeug_query_with_attributes(scim_client): response_user = scim_client.create(request_user) params = ResponseParameters(attributes=["displayName"]) - result = scim_client.query(User, response_user.id, query_parameters=params) + result = scim_client.query(response_user, query_parameters=params) assert result.display_name == "bar" assert result.title is None diff --git a/tests/test_delete.py b/tests/test_delete.py index 465e3d5..ab86421 100644 --- a/tests/test_delete.py +++ b/tests/test_delete.py @@ -1,7 +1,10 @@ import pytest from scim2_models import Error +from scim2_models import Meta from scim2_models import Resource +from scim2_models import ServiceProviderConfig from scim2_models import User +from scim2_models.resources.service_provider_config import ETag from scim2_client import RequestNetworkError from scim2_client import SCIMRequestError @@ -11,32 +14,30 @@ class UnregisteredResource(Resource): __schema__ = "urn:test:schemas:UnregisteredResource" -def test_delete_user(httpserver, sync_client): +def test_delete_user(httpserver, sync_client, user): """Nominal case for a User deletion.""" - httpserver.expect_request( - "/Users/2819c223-7f76-453a-919d-413861904646", method="DELETE" - ).respond_with_data(status=204, content_type="application/scim+json") + httpserver.expect_request(f"/Users/{user.id}", method="DELETE").respond_with_data( + status=204, content_type="application/scim+json" + ) - response = sync_client.delete(User, "2819c223-7f76-453a-919d-413861904646") + response = sync_client.delete(user) assert response is None -def test_delete_user_without_content_type_header(httpserver, sync_client): +def test_delete_user_without_content_type_header(httpserver, sync_client, user): """Server returns 204 without Content-Type header, which is valid per RFC 7231.""" - httpserver.expect_request( - "/Users/2819c223-7f76-453a-919d-413861904646", method="DELETE" - ).respond_with_data(status=204) + httpserver.expect_request(f"/Users/{user.id}", method="DELETE").respond_with_data( + status=204 + ) - response = sync_client.delete(User, "2819c223-7f76-453a-919d-413861904646") + response = sync_client.delete(user) assert response is None @pytest.mark.parametrize("code", [400, 401, 403, 404, 412, 500, 501]) -def test_errors(httpserver, code, sync_client): +def test_errors(httpserver, code, sync_client, user): """Test error cases defined in RFC7644.""" - httpserver.expect_request( - "/Users/2819c223-7f76-453a-919d-413861904646", method="DELETE" - ).respond_with_json( + httpserver.expect_request(f"/Users/{user.id}", method="DELETE").respond_with_json( { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": str(code), @@ -45,9 +46,7 @@ def test_errors(httpserver, code, sync_client): status=code, ) - response = sync_client.delete( - User, "2819c223-7f76-453a-919d-413861904646", raise_scim_errors=False - ) + response = sync_client.delete(user, raise_scim_errors=False) assert response == Error( schemas=["urn:ietf:params:scim:api:messages:2.0:Error"], @@ -56,17 +55,24 @@ def test_errors(httpserver, code, sync_client): ) +def test_delete_resource_without_id(sync_client): + """Deleting a resource without an id raises an error.""" + no_id_user = User(user_name="no-id") + with pytest.raises(SCIMRequestError, match="Resource must have an id"): + sync_client.delete(no_id_user) + + def test_invalid_resource_model(httpserver, sync_client): """Test that resource_models passed to the method must be part of SCIMClient.resource_models.""" + unregistered = UnregisteredResource() + unregistered.id = "foobar" with pytest.raises(SCIMRequestError, match=r"Unknown resource type"): - sync_client.delete(UnregisteredResource, id="foobar") + sync_client.delete(unregistered) -def test_dont_check_response_payload(httpserver, sync_client): +def test_dont_check_response_payload(httpserver, sync_client, user): """Test the check_response_payload attribute.""" - httpserver.expect_request( - "/Users/2819c223-7f76-453a-919d-413861904646", method="DELETE" - ).respond_with_json( + httpserver.expect_request(f"/Users/{user.id}", method="DELETE").respond_with_json( { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": "404", @@ -75,9 +81,7 @@ def test_dont_check_response_payload(httpserver, sync_client): status=404, ) - response = sync_client.delete( - User, "2819c223-7f76-453a-919d-413861904646", check_response_payload=False - ) + response = sync_client.delete(user, check_response_payload=False) assert response == { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": "404", @@ -85,9 +89,40 @@ def test_dont_check_response_payload(httpserver, sync_client): } -def test_request_network_error(httpserver, sync_client): +def test_request_network_error(httpserver, sync_client, user): """Test that httpx exceptions are transformed in RequestNetworkError.""" with pytest.raises( RequestNetworkError, match="Network error happened during request" ): - sync_client.delete(User, "anything", url="http://invalid.test") + sync_client.delete(user, url="http://invalid.test") + + +def test_delete_sends_if_match(httpserver, sync_client): + """If-Match header is sent when deleting a resource with ETag support.""" + sync_client.service_provider_config = ServiceProviderConfig( + etag=ETag(supported=True) + ) + user = User( + user_name="bjensen@example.com", + meta=Meta(version='W/"3694e05e9dff590"'), + ) + user.id = "2819c223-7f76-453a-919d-413861904646" + + httpserver.expect_request( + f"/Users/{user.id}", + method="DELETE", + headers={"If-Match": 'W/"3694e05e9dff590"'}, + ).respond_with_data(status=204, content_type="application/scim+json") + + response = sync_client.delete(user) + assert response is None + + +def test_delete_no_if_match_without_etag_support(httpserver, sync_client, user): + """No If-Match header when the server does not support ETags.""" + httpserver.expect_request(f"/Users/{user.id}", method="DELETE").respond_with_data( + status=204, content_type="application/scim+json" + ) + + response = sync_client.delete(user) + assert response is None diff --git a/tests/test_modify.py b/tests/test_modify.py index cef4956..962ce32 100644 --- a/tests/test_modify.py +++ b/tests/test_modify.py @@ -1,24 +1,25 @@ import pytest from scim2_models import Error from scim2_models import Group +from scim2_models import Meta from scim2_models import PatchOp from scim2_models import PatchOperation from scim2_models import ResourceType +from scim2_models import ServiceProviderConfig from scim2_models import User +from scim2_models.resources.service_provider_config import ETag from scim2_client import RequestNetworkError from scim2_client import RequestPayloadValidationError from scim2_client import SCIMRequestError -def test_modify_user_200(httpserver, sync_client): +def test_modify_user_200(httpserver, sync_client, user): """Nominal case for a User modification with 200 response (resource returned).""" - httpserver.expect_request( - "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" - ).respond_with_json( + httpserver.expect_request(f"/Users/{user.id}", method="PATCH").respond_with_json( { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - "id": "2819c223-7f76-453a-919d-413861904646", + "id": user.id, "userName": "bjensen@example.com", "displayName": "Updated Display Name", "meta": { @@ -26,7 +27,7 @@ def test_modify_user_200(httpserver, sync_client): "created": "2010-01-23T04:56:22Z", "lastModified": "2011-05-13T04:42:34Z", "version": 'W\\/"3694e05e9dff590"', - "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", + "location": f"https://example.com/v2/Users/{user.id}", }, }, status=200, @@ -38,20 +39,16 @@ def test_modify_user_200(httpserver, sync_client): ) patch_op = PatchOp[User](operations=[operation]) - response = sync_client.modify( - User, "2819c223-7f76-453a-919d-413861904646", patch_op - ) + response = sync_client.modify(user, patch_op) assert isinstance(response, User) - assert response.id == "2819c223-7f76-453a-919d-413861904646" + assert response.id == user.id assert response.display_name == "Updated Display Name" -def test_modify_user_204(httpserver, sync_client): +def test_modify_user_204(httpserver, sync_client, user): """Nominal case for a User modification with 204 response (no content).""" - httpserver.expect_request( - "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" - ).respond_with_data( + httpserver.expect_request(f"/Users/{user.id}", method="PATCH").respond_with_data( "", status=204, content_type="application/scim+json", @@ -62,18 +59,14 @@ def test_modify_user_204(httpserver, sync_client): ) patch_op = PatchOp[User](operations=[operation]) - response = sync_client.modify( - User, "2819c223-7f76-453a-919d-413861904646", patch_op - ) + response = sync_client.modify(user, patch_op) assert response is None -def test_modify_user_204_without_content_type_header(httpserver, sync_client): +def test_modify_user_204_without_content_type_header(httpserver, sync_client, user): """Server returns 204 without Content-Type header, which is valid per RFC 7231.""" - httpserver.expect_request( - "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" - ).respond_with_data( + httpserver.expect_request(f"/Users/{user.id}", method="PATCH").respond_with_data( "", status=204, ) @@ -83,21 +76,17 @@ def test_modify_user_204_without_content_type_header(httpserver, sync_client): ) patch_op = PatchOp[User](operations=[operation]) - response = sync_client.modify( - User, "2819c223-7f76-453a-919d-413861904646", patch_op - ) + response = sync_client.modify(user, patch_op) assert response is None -def test_modify_user_multiple_operations(httpserver, sync_client): +def test_modify_user_multiple_operations(httpserver, sync_client, user): """Test User modification with multiple patch operations.""" - httpserver.expect_request( - "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" - ).respond_with_json( + httpserver.expect_request(f"/Users/{user.id}", method="PATCH").respond_with_json( { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - "id": "2819c223-7f76-453a-919d-413861904646", + "id": user.id, "userName": "bjensen@example.com", "displayName": "Betty Jane", "active": False, @@ -106,7 +95,7 @@ def test_modify_user_multiple_operations(httpserver, sync_client): "created": "2010-01-23T04:56:22Z", "lastModified": "2011-05-13T04:42:34Z", "version": 'W\\/"3694e05e9dff591"', - "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", + "location": f"https://example.com/v2/Users/{user.id}", }, }, status=200, @@ -121,23 +110,19 @@ def test_modify_user_multiple_operations(httpserver, sync_client): ] patch_op = PatchOp[User](operations=operations) - response = sync_client.modify( - User, "2819c223-7f76-453a-919d-413861904646", patch_op - ) + response = sync_client.modify(user, patch_op) assert isinstance(response, User) assert response.display_name == "Betty Jane" assert response.active is False -def test_modify_user_add_operation(httpserver, sync_client): +def test_modify_user_add_operation(httpserver, sync_client, user): """Test User modification with add operation.""" - httpserver.expect_request( - "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" - ).respond_with_json( + httpserver.expect_request(f"/Users/{user.id}", method="PATCH").respond_with_json( { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - "id": "2819c223-7f76-453a-919d-413861904646", + "id": user.id, "userName": "bjensen@example.com", "emails": [{"value": "bjensen@example.com", "primary": True}], "meta": { @@ -145,7 +130,7 @@ def test_modify_user_add_operation(httpserver, sync_client): "created": "2010-01-23T04:56:22Z", "lastModified": "2011-05-13T04:42:34Z", "version": 'W\\/"3694e05e9dff591"', - "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", + "location": f"https://example.com/v2/Users/{user.id}", }, }, status=200, @@ -159,30 +144,26 @@ def test_modify_user_add_operation(httpserver, sync_client): ) patch_op = PatchOp[User](operations=[operation]) - response = sync_client.modify( - User, "2819c223-7f76-453a-919d-413861904646", patch_op - ) + response = sync_client.modify(user, patch_op) assert isinstance(response, User) assert len(response.emails) == 1 assert response.emails[0].value == "bjensen@example.com" -def test_modify_user_remove_operation(httpserver, sync_client): +def test_modify_user_remove_operation(httpserver, sync_client, user): """Test User modification with remove operation.""" - httpserver.expect_request( - "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" - ).respond_with_json( + httpserver.expect_request(f"/Users/{user.id}", method="PATCH").respond_with_json( { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - "id": "2819c223-7f76-453a-919d-413861904646", + "id": user.id, "userName": "bjensen@example.com", "meta": { "resourceType": "User", "created": "2010-01-23T04:56:22Z", "lastModified": "2011-05-13T04:42:34Z", "version": 'W\\/"3694e05e9dff591"', - "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", + "location": f"https://example.com/v2/Users/{user.id}", }, }, status=200, @@ -192,29 +173,25 @@ def test_modify_user_remove_operation(httpserver, sync_client): operation = PatchOperation(op=PatchOperation.Op.remove, path="displayName") patch_op = PatchOp[User](operations=[operation]) - response = sync_client.modify( - User, "2819c223-7f76-453a-919d-413861904646", patch_op - ) + response = sync_client.modify(user, patch_op) assert isinstance(response, User) assert response.display_name is None -def test_modify_group(httpserver, sync_client): +def test_modify_group(httpserver, sync_client, group): """Test Group modification.""" - httpserver.expect_request( - "/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a", method="PATCH" - ).respond_with_json( + httpserver.expect_request(f"/Groups/{group.id}", method="PATCH").respond_with_json( { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], - "id": "e9e30dba-f08f-4109-8486-d5c6a331660a", + "id": group.id, "displayName": "Updated Tour Guides", "meta": { "resourceType": "Group", "created": "2010-01-23T04:56:22Z", "lastModified": "2011-05-13T04:42:34Z", "version": 'W\\/"3694e05e9dff592"', - "location": "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a", + "location": f"https://example.com/v2/Groups/{group.id}", }, }, status=200, @@ -226,19 +203,17 @@ def test_modify_group(httpserver, sync_client): ) patch_op = PatchOp[Group](operations=[operation]) - response = sync_client.modify( - Group, "e9e30dba-f08f-4109-8486-d5c6a331660a", patch_op - ) + response = sync_client.modify(group, patch_op) assert isinstance(response, Group) assert response.display_name == "Updated Tour Guides" -def test_dont_check_response_payload(httpserver, sync_client): +def test_dont_check_response_payload(httpserver, sync_client, user): """Test the check_response_payload attribute.""" - httpserver.expect_request( - "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" - ).respond_with_json({"foo": "bar"}, status=200) + httpserver.expect_request(f"/Users/{user.id}", method="PATCH").respond_with_json( + {"foo": "bar"}, status=200 + ) operation = PatchOperation( op=PatchOperation.Op.replace_, path="displayName", value="Test" @@ -246,22 +221,19 @@ def test_dont_check_response_payload(httpserver, sync_client): patch_op = PatchOp[User](operations=[operation]) response = sync_client.modify( - User, - "2819c223-7f76-453a-919d-413861904646", + user, patch_op, check_response_payload=False, ) assert response == {"foo": "bar"} -def test_dont_check_request_payload(httpserver, sync_client): +def test_dont_check_request_payload(httpserver, sync_client, user): """Test the check_request_payload attribute.""" - httpserver.expect_request( - "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" - ).respond_with_json( + httpserver.expect_request(f"/Users/{user.id}", method="PATCH").respond_with_json( { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - "id": "2819c223-7f76-453a-919d-413861904646", + "id": user.id, "userName": "bjensen@example.com", "displayName": "Updated Name", }, @@ -277,21 +249,18 @@ def test_dont_check_request_payload(httpserver, sync_client): } response = sync_client.modify( - User, - "2819c223-7f76-453a-919d-413861904646", + user, patch_op_dict, check_request_payload=False, ) - assert response.id == "2819c223-7f76-453a-919d-413861904646" + assert response.id == user.id assert response.display_name == "Updated Name" @pytest.mark.parametrize("code", [400, 401, 403, 404, 409, 412, 500, 501]) -def test_errors(httpserver, code, sync_client): +def test_errors(httpserver, code, sync_client, user): """Test error cases defined in RFC7644.""" - httpserver.expect_request( - "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" - ).respond_with_json( + httpserver.expect_request(f"/Users/{user.id}", method="PATCH").respond_with_json( { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": str(code), @@ -306,9 +275,7 @@ def test_errors(httpserver, code, sync_client): ) patch_op = PatchOp[User](operations=[operation]) - response = sync_client.modify( - User, "2819c223-7f76-453a-919d-413861904646", patch_op, raise_scim_errors=False - ) + response = sync_client.modify(user, patch_op, raise_scim_errors=False) assert response == Error( schemas=["urn:ietf:params:scim:api:messages:2.0:Error"], @@ -317,7 +284,7 @@ def test_errors(httpserver, code, sync_client): ) -def test_invalid_resource_model(httpserver, sync_client): +def test_invalid_resource_model(httpserver, sync_client, group): """Test that resource_models passed to the method must be part of SCIMClient.resource_models.""" sync_client.resource_models = (User,) sync_client.resource_types = [ResourceType.from_resource(User)] @@ -328,10 +295,10 @@ def test_invalid_resource_model(httpserver, sync_client): patch_op = PatchOp[Group](operations=[operation]) with pytest.raises(SCIMRequestError, match=r"Unknown resource type"): - sync_client.modify(Group, "some-id", patch_op) + sync_client.modify(group, patch_op) -def test_request_validation_error(httpserver, sync_client): +def test_request_validation_error(httpserver, sync_client, user): """Test that incorrect PatchOp creation raises a validation error.""" # Test with a PatchOp that has invalid data - this should fail during model_dump in prepare_patch_request with pytest.raises( @@ -344,10 +311,10 @@ def test_request_validation_error(httpserver, sync_client): invalid_patch_op = Mock() invalid_patch_op.model_dump.side_effect = ValueError("Invalid operation type") - sync_client.modify(User, "some-id", invalid_patch_op) + sync_client.modify(user, invalid_patch_op) -def test_request_network_error(httpserver, sync_client): +def test_request_network_error(httpserver, sync_client, user): """Test that httpx exceptions are transformed in RequestNetworkError.""" operation = PatchOperation( op=PatchOperation.Op.replace_, path="displayName", value="Test" @@ -357,10 +324,10 @@ def test_request_network_error(httpserver, sync_client): with pytest.raises( RequestNetworkError, match="Network error happened during request" ): - sync_client.modify(User, "some-id", patch_op, url="http://invalid.test") + sync_client.modify(user, patch_op, url="http://invalid.test") -def test_custom_url(httpserver, sync_client): +def test_custom_url(httpserver, sync_client, user): """Test modify with custom URL.""" httpserver.expect_request( "/custom/path/users/123", method="PATCH" @@ -375,16 +342,14 @@ def test_custom_url(httpserver, sync_client): ) patch_op = PatchOp[User](operations=[operation]) - response = sync_client.modify(User, "123", patch_op, url="/custom/path/users/123") + response = sync_client.modify(user, patch_op, url="/custom/path/users/123") assert response is None -def test_modify_with_dict_patch_op(httpserver, sync_client): +def test_modify_with_dict_patch_op(httpserver, sync_client, user): """Test modify with dict patch_op.""" - httpserver.expect_request( - "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" - ).respond_with_data( + httpserver.expect_request(f"/Users/{user.id}", method="PATCH").respond_with_data( "", status=204, content_type="application/scim+json", @@ -397,8 +362,7 @@ def test_modify_with_dict_patch_op(httpserver, sync_client): } response = sync_client.modify( - User, - "2819c223-7f76-453a-919d-413861904646", + user, patch_op_dict, check_request_payload=True, ) @@ -406,7 +370,7 @@ def test_modify_with_dict_patch_op(httpserver, sync_client): assert response is None -def test_modify_validation_error(httpserver, sync_client): +def test_modify_validation_error(httpserver, sync_client, user): """Test that PatchOp validation errors are handled properly.""" from unittest.mock import Mock @@ -426,4 +390,29 @@ def test_modify_validation_error(httpserver, sync_client): RequestPayloadValidationError, match="Server request payload validation error", ): - sync_client.modify(User, "some-id", invalid_patch_op) + sync_client.modify(user, invalid_patch_op) + + +def test_modify_sends_if_match(httpserver, sync_client): + """If-Match header is sent when modifying a resource with ETag support.""" + sync_client.service_provider_config = ServiceProviderConfig( + etag=ETag(supported=True) + ) + user = User( + user_name="bjensen@example.com", + meta=Meta(version='W/"3694e05e9dff590"'), + ) + user.id = "2819c223-7f76-453a-919d-413861904646" + + httpserver.expect_request( + f"/Users/{user.id}", + method="PATCH", + headers={"If-Match": 'W/"3694e05e9dff590"'}, + ).respond_with_data(status=204, content_type="application/scim+json") + + operation = PatchOperation( + op=PatchOperation.Op.replace_, path="displayName", value="Updated" + ) + patch_op = PatchOp[User](operations=[operation]) + response = sync_client.modify(user, patch_op) + assert response is None diff --git a/tests/test_query.py b/tests/test_query.py index dceb548..fad2f72 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -11,6 +11,7 @@ from scim2_models import SearchRequest from scim2_models import ServiceProviderConfig from scim2_models import User +from scim2_models.resources.service_provider_config import ETag from scim2_client import SCIMRequestError from scim2_client.errors import RequestNetworkError @@ -298,7 +299,7 @@ def httpserver(httpserver): def test_user_with_valid_id(sync_client): """Test that querying an existing user with an id correctly instantiate an User object.""" response = sync_client.query( - User, "2819c223-7f76-453a-919d-413861904646", raise_scim_errors=False + User(id="2819c223-7f76-453a-919d-413861904646"), raise_scim_errors=False ) assert response == User( id="2819c223-7f76-453a-919d-413861904646", @@ -319,7 +320,7 @@ def test_user_with_valid_id(sync_client): def test_user_with_invalid_id(sync_client): """Test that querying an user with an invalid id instantiate an Error object.""" - response = sync_client.query(User, "unknown", raise_scim_errors=False) + response = sync_client.query(User(id="unknown"), raise_scim_errors=False) assert response == Error(detail="Resource unknown not found", status=404) @@ -329,7 +330,7 @@ def test_raise_scim_errors(sync_client): SCIMResponseErrorObject, match="Resource unknown not found", ) as exc_info: - sync_client.query(User, "unknown", raise_scim_errors=True) + sync_client.query(User(id="unknown"), raise_scim_errors=True) assert exc_info.value.to_error() == Error( detail="Resource unknown not found", status=404 @@ -342,7 +343,7 @@ def test_raise_scim_errors_with_scim_type(sync_client): SCIMResponseErrorObject, match="uniqueness: User already exists", ) as exc_info: - sync_client.query(User, "conflict", raise_scim_errors=True) + sync_client.query(User(id="conflict"), raise_scim_errors=True) assert exc_info.value.to_error() == Error( detail="User already exists", status=409, scim_type="uniqueness" @@ -355,7 +356,7 @@ def test_raise_scim_errors_without_detail(sync_client): SCIMResponseErrorObject, match="SCIM Error", ) as exc_info: - sync_client.query(User, "no-detail", raise_scim_errors=True) + sync_client.query(User(id="no-detail"), raise_scim_errors=True) assert exc_info.value.to_error() == Error(status=500) @@ -428,7 +429,7 @@ def test_no_result(sync_client): def test_bad_request(sync_client): """Test querying a resource unknown from the server instantiate an Error object.""" - response = sync_client.query(User, "bad-request", raise_scim_errors=False) + response = sync_client.query(User(id="bad-request"), raise_scim_errors=False) assert response == Error(status=400, detail="Bad request") @@ -456,7 +457,7 @@ def test_bad_resource_model(sync_client): SCIMResponseError, match="Expected type User but got unknown resource with schemas: urn:ietf:params:scim:schemas:core:2.0:Group", ): - sync_client.query(User, "its-a-group") + sync_client.query(User(id="its-a-group")) def test_all(sync_client): @@ -483,7 +484,7 @@ def test_all_unexpected_type(sync_client): def test_response_is_not_json(sync_client): """Test situations where servers return an invalid JSON object.""" with pytest.raises(UnexpectedContentFormat): - sync_client.query(User, "not-json") + sync_client.query(User(id="not-json")) def test_not_a_scim_object(sync_client): @@ -492,13 +493,13 @@ def test_not_a_scim_object(sync_client): SCIMResponseError, match="Expected type User but got undefined object with no schema", ): - sync_client.query(User, "not-a-scim-object") + sync_client.query(User(id="not-a-scim-object")) def test_dont_check_response_payload(sync_client): """Test the check_response_payload attribute.""" response = sync_client.query( - User, "not-a-scim-object", check_response_payload=False + User(id="not-a-scim-object"), check_response_payload=False ) assert response == {"foo": "bar"} @@ -506,20 +507,20 @@ def test_dont_check_response_payload(sync_client): def test_response_bad_status_code(sync_client): """Test situations where servers return an invalid status code.""" with pytest.raises(UnexpectedStatusCode): - sync_client.query(User, "status-201") - sync_client.query(User, "status-201", expected_status_codes=None) + sync_client.query(User(id="status-201")) + sync_client.query(User(id="status-201"), expected_status_codes=None) def test_response_content_type_with_charset(sync_client): """Test situations where servers return a valid content-type with a charset information.""" - user = sync_client.query(User, "content-type-with-charset") + user = sync_client.query(User(id="content-type-with-charset")) assert isinstance(user, User) def test_response_bad_content_type(sync_client): """Test situations where servers return an invalid content-type response.""" with pytest.raises(UnexpectedContentType): - sync_client.query(User, "bad-content-type") + sync_client.query(User(id="bad-content-type")) def test_search_request(httpserver, sync_client): @@ -551,7 +552,7 @@ def test_search_request(httpserver, sync_client): count=10, ) - response = sync_client.query(User, "with-qs", req) + response = sync_client.query(User(id="with-qs"), req) assert isinstance(response, User) assert response.id == "with-qs" @@ -578,7 +579,7 @@ def test_query_parameters(httpserver, sync_client): status=200, ) params = ResponseParameters(attributes=["userName", "displayName"]) - response = sync_client.query(User, "with-rp", params) + response = sync_client.query(User(id="with-rp"), params) assert isinstance(response, User) assert response.id == "with-rp" @@ -614,7 +615,7 @@ def test_query_dont_check_request_payload(httpserver, sync_client): "count": 10, } - response = sync_client.query(User, "with-qs", req, check_request_payload=False) + response = sync_client.query(User(id="with-qs"), req, check_request_payload=False) assert isinstance(response, User) assert response.id == "with-qs" @@ -642,7 +643,7 @@ def test_deprecated_search_request_keyword(httpserver, sync_client): ) params = ResponseParameters(attributes=["userName"]) with pytest.warns(DeprecationWarning, match="search_request.*deprecated"): - response = sync_client.query(User, "with-dep", search_request=params) + response = sync_client.query(User(id="with-dep"), search_request=params) assert isinstance(response, User) assert response.id == "with-dep" @@ -651,7 +652,7 @@ def test_both_search_request_and_query_parameters_raises(sync_client): """Passing both search_request and query_parameters raises TypeError.""" params = ResponseParameters(attributes=["userName"]) with pytest.raises(TypeError, match="Cannot pass both"): - sync_client.query(User, "some-id", params, search_request=params) + sync_client.query(User(id="some-id"), params, search_request=params) def test_invalid_resource_model(sync_client): @@ -674,7 +675,9 @@ def test_service_provider_config_endpoint_with_an_id(sync_client): with pytest.raises( SCIMClientError, match="ServiceProviderConfig cannot have an id" ): - sync_client.query(ServiceProviderConfig, "dummy") + spc = ServiceProviderConfig() + spc.id = "dummy" + sync_client.query(spc) def test_request_network_error(sync_client): @@ -683,3 +686,147 @@ def test_request_network_error(sync_client): RequestNetworkError, match="Network error happened during request" ): sync_client.query(url="http://invalid.test") + + +def test_query_sends_if_none_match(httpserver, sync_client): + """If-None-Match is sent when querying a resource instance with ETag support.""" + sync_client.service_provider_config = ServiceProviderConfig( + etag=ETag(supported=True) + ) + user = User( + id="etag-304-user", + user_name="bjensen@example.com", + meta=Meta(version='W/"3694e05e9dff590"'), + ) + + httpserver.expect_request( + "/Users/etag-304-user", + headers={"If-None-Match": 'W/"3694e05e9dff590"'}, + ).respond_with_data(status=304) + + response = sync_client.query(user) + assert response is user + + +def test_query_returns_fresh_resource_on_200(httpserver, sync_client): + """Server returns 200 with updated resource when ETag does not match.""" + sync_client.service_provider_config = ServiceProviderConfig( + etag=ETag(supported=True) + ) + user = User( + id="etag-200-user", + user_name="bjensen@example.com", + meta=Meta(version='W/"old-version"'), + ) + + httpserver.expect_request( + "/Users/etag-200-user", + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "etag-200-user", + "userName": "bjensen@example.com", + "displayName": "Updated Name", + "meta": { + "resourceType": "User", + "created": "2010-01-23T04:56:22Z", + "lastModified": "2011-05-13T04:42:34Z", + "version": 'W/"new-version"', + "location": "https://example.com/v2/Users/etag-200-user", + }, + }, + status=200, + ) + + response = sync_client.query(user) + assert response is not user + assert response.display_name == "Updated Name" + + +def test_query_no_if_none_match_without_version(httpserver, sync_client): + """No If-None-Match header when the resource has no meta.version.""" + sync_client.service_provider_config = ServiceProviderConfig( + etag=ETag(supported=True) + ) + + httpserver.expect_request( + "/Users/etag-no-version", + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "etag-no-version", + "userName": "bjensen@example.com", + "meta": { + "resourceType": "User", + "created": "2010-01-23T04:56:22Z", + "lastModified": "2011-05-13T04:42:34Z", + "location": "https://example.com/v2/Users/etag-no-version", + }, + }, + status=200, + ) + + user = User(id="etag-no-version") + response = sync_client.query(user) + assert isinstance(response, User) + + +def test_query_no_if_none_match_without_etag_support(httpserver, sync_client): + """No If-None-Match header when the server does not support ETags.""" + httpserver.expect_request( + "/Users/etag-unsupported", + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "etag-unsupported", + "userName": "bjensen@example.com", + "meta": { + "resourceType": "User", + "created": "2010-01-23T04:56:22Z", + "lastModified": "2011-05-13T04:42:34Z", + "version": 'W/"3694e05e9dff590"', + "location": "https://example.com/v2/Users/etag-unsupported", + }, + }, + status=200, + ) + + user = User( + id="etag-unsupported", + meta=Meta(version='W/"3694e05e9dff590"'), + ) + response = sync_client.query(user) + assert isinstance(response, User) + + +def test_query_no_if_none_match_with_query_parameters(httpserver, sync_client): + """No If-None-Match header when query_parameters are present.""" + sync_client.service_provider_config = ServiceProviderConfig( + etag=ETag(supported=True) + ) + + httpserver.expect_request( + "/Users/etag-with-params", + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "etag-with-params", + "userName": "bjensen@example.com", + "meta": { + "resourceType": "User", + "created": "2010-01-23T04:56:22Z", + "lastModified": "2011-05-13T04:42:34Z", + "version": 'W/"3694e05e9dff590"', + "location": "https://example.com/v2/Users/etag-with-params", + }, + }, + status=200, + ) + + user = User( + id="etag-with-params", + meta=Meta(version='W/"3694e05e9dff590"'), + ) + params = ResponseParameters(attributes=["userName"]) + response = sync_client.query(user, params) + assert isinstance(response, User) diff --git a/tests/test_replace.py b/tests/test_replace.py index 0372dcc..da77915 100644 --- a/tests/test_replace.py +++ b/tests/test_replace.py @@ -5,7 +5,9 @@ from scim2_models import Group from scim2_models import Meta from scim2_models import ResourceType +from scim2_models import ServiceProviderConfig from scim2_models import User +from scim2_models.resources.service_provider_config import ETag from scim2_client import RequestNetworkError from scim2_client import RequestPayloadValidationError @@ -310,3 +312,38 @@ def test_request_network_error(httpserver, sync_client): RequestNetworkError, match="Network error happened during request" ): sync_client.replace(user_request, url="http://invalid.test") + + +def test_replace_sends_if_match(httpserver, sync_client): + """If-Match header is sent when replacing a resource with ETag support.""" + sync_client.service_provider_config = ServiceProviderConfig( + etag=ETag(supported=True) + ) + user = User( + id="2819c223-7f76-453a-919d-413861904646", + user_name="bjensen@example.com", + meta=Meta(version='W/"3694e05e9dff590"'), + ) + + httpserver.expect_request( + f"/Users/{user.id}", + method="PUT", + headers={"If-Match": 'W/"3694e05e9dff590"'}, + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": user.id, + "userName": "bjensen@example.com", + "meta": { + "resourceType": "User", + "created": "2010-01-23T04:56:22Z", + "lastModified": "2011-05-13T04:42:34Z", + "version": 'W/"new-version"', + "location": f"https://example.com/v2/Users/{user.id}", + }, + }, + status=200, + ) + + response = sync_client.replace(user) + assert isinstance(response, User)