diff --git a/doc/changelog.rst b/doc/changelog.rst index 28c1a60..531bf9f 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -4,6 +4,10 @@ Changelog [Unreleased] ------------ +Added +^^^^^ +- Support for bulk operations with :meth:`~scim2_client.BaseSyncSCIMClient.bulk`. + Changed ^^^^^^^ - scim2-models 0.8.0 is the minimum supported version. diff --git a/doc/tutorial.rst b/doc/tutorial.rst index ba495db..57d9ec5 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -339,10 +339,66 @@ Modify Bulk ~~~~ -.. note:: +:meth:`~scim2_client.BaseSyncSCIMClient.bulk` issues a ``POST`` on the ``/Bulk`` endpoint to execute multiple operations at once: + +.. tab-set:: + :class: outline + + .. tab-item:: Sync + :sync: sync + + .. code-block:: python - Bulk operation requests are not yet implemented, - but :doc:`any help is welcome! ` + from scim2_models import BulkRequest, BulkOperation, Group, GroupMember, User + + request = BulkRequest[User | Group]( + operations=[ + BulkOperation[User]( + method="POST", + path="/Users", + bulk_id="qwerty", + data=User(user_name="Alice"), + ), + BulkOperation[Group]( + method="POST", + path="/Groups", + bulk_id="ytrewq", + data=Group( + display_name="Tour Guides", + members=[GroupMember(type="User", value="bulkId:qwerty")], + ), + ), + ] + ) + response = scim.bulk(request) + + .. tab-item:: Async + :sync: async + + .. code-block:: python + + from scim2_models import BulkRequest, BulkOperation, Group, GroupMember, User + + request = BulkRequest[User | Group]( + operations=[ + BulkOperation[User]( + method="POST", + path="/Users", + bulk_id="qwerty", + data=User(user_name="Alice"), + ), + BulkOperation[Group]( + method="POST", + path="/Groups", + bulk_id="ytrewq", + data=Group( + display_name="Tour Guides", + members=[GroupMember(type="User", value="bulkId:qwerty")], + ), + ), + ] + ) + response = await scim.bulk(request) Error handling ============== diff --git a/scim2_client/client.py b/scim2_client/client.py index 1f6676c..a1290a5 100644 --- a/scim2_client/client.py +++ b/scim2_client/client.py @@ -9,6 +9,8 @@ from pydantic import ValidationError from scim2_models import AnyResource +from scim2_models import BulkRequest +from scim2_models import BulkResponse from scim2_models import Context from scim2_models import Error from scim2_models import Extension @@ -134,6 +136,25 @@ class SCIMClient: :rfc:`RFC7644 §3.12 <7644#section-3.12>`. """ + BULK_RESPONSE_STATUS_CODES: list[int] = [ + 200, + 307, + 308, + 400, + 401, + 403, + 404, + 409, + 413, + 500, + 501, + ] + """Bulk request HTTP codes. + + As defined at :rfc:`RFC7644 §3.7 <7644#section-3.7>` and + :rfc:`RFC7644 §3.12 <7644#section-3.12>`. + """ + DELETION_RESPONSE_STATUS_CODES: list[int] = [ 204, 307, @@ -654,6 +675,35 @@ def _prepare_search_request( req.expected_types = [ListResponse[Union[self.resource_models]]] # noqa: UP007 return req + def _prepare_bulk_request( + self, + bulk_request: BulkRequest | None = None, + check_request_payload: bool | None = None, + expected_status_codes: list[int] | None = None, + **kwargs, + ) -> RequestPayload: + req = RequestPayload( + expected_status_codes=expected_status_codes, + request_kwargs=kwargs, + ) + + if check_request_payload is None: + check_request_payload = self.check_request_payload + + if not check_request_payload: + req.payload = bulk_request + + else: + req.payload = ( + bulk_request.model_dump(scim_ctx=Context.BULK_REQUEST) + if bulk_request + else None + ) + + req.url = req.request_kwargs.pop("url", "/Bulk") + req.expected_types = [BulkResponse[Union[self.resource_models]]] # noqa: UP007 + return req + def _prepare_delete_request( self, resource: Resource | type[Resource] | None = None, @@ -1016,6 +1066,74 @@ def search( """ raise NotImplementedError() + def bulk( + self, + bulk_request: BulkRequest | None = None, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] | None = SCIMClient.BULK_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> BulkResponse | Error | dict: + """Perform a POST bulk request to execute bulk operations, as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`. + + :param bulk_request: An object detailing the bulk request. + :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. + If :data:`None` any status code is accepted. + :param raise_scim_errors: If set, overwrites :paramref:`scim2_client.SCIMClient.raise_scim_errors`. + :param kwargs: Additional parameters passed to the underlying + HTTP request library. + + :return: + - A :class:`~scim2_models.Error` object in case of error. + - A :class:`~scim2_models.BulkResponse` object in case of success. + + :usage: + + .. code-block:: python + :caption: Simultaneously creating a `User` resource and a `Group` resource containing the user + + from scim2_models import ( + BulkRequest, + BulkOperation, + Group, + GroupMember, + User, + ) + + req = BulkRequest[User | Group]( + operations=[ + BulkOperation[User]( + method="POST", + path="/Users", + bulk_id="qwerty", + data=User(user_name="Alice"), + ), + BulkOperation[Group]( + method="POST", + path="/Groups", + bulk_id="ytrewq", + data=Group( + display_name="Tour Guides", + members=[GroupMember(type="User", value="bulkId:qwerty")], + ), + ), + ] + ) + response = scim.bulk(req) + # 'response' may be a BulkResponse or an Error object + + .. tip:: + + Check the :attr:`~scim2_models.Context.BULK_REQUEST` + and :attr:`~scim2_models.Context.BULK_RESPONSE` contexts to understand + which values will be excluded from the request payload, and which values are expected in + the response payload. + """ + raise NotImplementedError() + def delete( self, resource: Resource | type[Resource] | None = None, @@ -1382,6 +1500,74 @@ async def search( """ raise NotImplementedError() + async def bulk( + self, + bulk_request: BulkRequest | None = None, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] | None = SCIMClient.BULK_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> BulkResponse | Error | dict: + """Perform a POST bulk request to execute bulk operations, as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`. + + :param bulk_request: An object detailing the bulk request. + :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. + If :data:`None` any status code is accepted. + :param raise_scim_errors: If set, overwrites :paramref:`scim2_client.SCIMClient.raise_scim_errors`. + :param kwargs: Additional parameters passed to the underlying + HTTP request library. + + :return: + - A :class:`~scim2_models.Error` object in case of error. + - A :class:`~scim2_models.BulkResponse` object in case of success. + + :usage: + + .. code-block:: python + :caption: Simultaneously creating a `User` resource and a `Group` resource containing the user + + from scim2_models import ( + BulkRequest, + BulkOperation, + Group, + GroupMember, + User, + ) + + req = BulkRequest[User | Group]( + operations=[ + BulkOperation[User]( + method="POST", + path="/Users", + bulk_id="qwerty", + data=User(user_name="Alice"), + ), + BulkOperation[Group]( + method="POST", + path="/Groups", + bulk_id="ytrewq", + data=Group( + display_name="Tour Guides", + members=[GroupMember(type="User", value="bulkId:qwerty")], + ), + ), + ] + ) + response = scim.bulk(req) + # 'response' may be a BulkResponse or an Error object + + .. tip:: + + Check the :attr:`~scim2_models.Context.BULK_REQUEST` + and :attr:`~scim2_models.Context.BULK_RESPONSE` contexts to understand + which values will be excluded from the request payload, and which values are expected in + the response payload. + """ + raise NotImplementedError() + async def delete( self, resource: Resource | type[Resource] | None = None, diff --git a/scim2_client/engines/httpx2.py b/scim2_client/engines/httpx2.py index c6a2eee..22b9e18 100644 --- a/scim2_client/engines/httpx2.py +++ b/scim2_client/engines/httpx2.py @@ -22,6 +22,8 @@ from httpx import Response # type: ignore[assignment] from scim2_models import AnyResource +from scim2_models import BulkRequest +from scim2_models import BulkResponse from scim2_models import Context from scim2_models import Error from scim2_models import ListResponse @@ -216,6 +218,38 @@ def search( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, ) + def bulk( + self, + bulk_request: BulkRequest | None = None, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseSyncSCIMClient.BULK_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> BulkResponse | Error | dict: + req = self._prepare_bulk_request( + bulk_request=bulk_request, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + with handle_request_error(req.payload): + response = self.client.post(req.url, json=req.payload, **req.request_kwargs) + + with handle_response_error(response): + return self.check_response( + payload=response.json() if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=req.expected_status_codes, + expected_types=req.expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.BULK_RESPONSE, + ) + def delete( self, resource: Resource | type[Resource] | None = None, @@ -450,6 +484,40 @@ async def search( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, ) + async def bulk( + self, + bulk_request: BulkRequest | None = None, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseAsyncSCIMClient.BULK_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> BulkResponse | Error | dict: + req = self._prepare_bulk_request( + bulk_request=bulk_request, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + with handle_request_error(req.payload): + response = await self.client.post( + req.url, json=req.payload, **req.request_kwargs + ) + + with handle_response_error(response): + return self.check_response( + payload=response.json() if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=req.expected_status_codes, + expected_types=req.expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.BULK_RESPONSE, + ) + async def delete( self, resource: Resource | type[Resource] | None = None, diff --git a/scim2_client/engines/werkzeug.py b/scim2_client/engines/werkzeug.py index 86f05de..d826870 100644 --- a/scim2_client/engines/werkzeug.py +++ b/scim2_client/engines/werkzeug.py @@ -4,6 +4,8 @@ from urllib.parse import urlencode from scim2_models import AnyResource +from scim2_models import BulkRequest +from scim2_models import BulkResponse from scim2_models import Context from scim2_models import Error from scim2_models import ListResponse @@ -215,6 +217,40 @@ def search( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, ) + def bulk( + self, + bulk_request: BulkRequest | None = None, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseSyncSCIMClient.BULK_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> BulkResponse | Error | dict: + req = self._prepare_bulk_request( + bulk_request=bulk_request, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + environ = {**self.environ, **req.request_kwargs} + response = self.client.post( + self._make_url(req.url), json=req.payload, **environ + ) + + with handle_response_error(response): + return self.check_response( + payload=response.json if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=req.expected_status_codes, + expected_types=req.expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.BULK_RESPONSE, + ) + def delete( self, resource: Resource | type[Resource] | None = None, diff --git a/tests/engines/test_httpx2.py b/tests/engines/test_httpx2.py index 9c3471d..43ad235 100644 --- a/tests/engines/test_httpx2.py +++ b/tests/engines/test_httpx2.py @@ -3,6 +3,8 @@ import portpicker import pytest +from scim2_models import BulkOperation +from scim2_models import BulkRequest from scim2_models import PatchOp from scim2_models import PatchOperation from scim2_models import SCIMException @@ -96,6 +98,21 @@ def test_sync_engine(server): with pytest.raises(SCIMException): scim_client.query(User, response_user.id) + # Bulk operations are not implemented yet in scim2-server + with pytest.raises(SCIMException): + scim_client.bulk( + BulkRequest[User]( + operations=[ + BulkOperation[User]( + method="POST", + path="/Users", + bulk_id="qwerty", + data=User(user_name="Alice"), + ) + ] + ) + ) + async def test_async_engine(server): host, port = server @@ -154,3 +171,18 @@ async def test_async_engine(server): await scim_client.delete(User, response_user.id) with pytest.raises(SCIMException): await scim_client.query(User, response_user.id) + + # Bulk operations are not implemented yet in scim2-server + with pytest.raises(SCIMException): + await scim_client.bulk( + BulkRequest[User]( + operations=[ + BulkOperation[User]( + method="POST", + path="/Users", + bulk_id="qwerty", + data=User(user_name="Alice"), + ) + ] + ) + ) diff --git a/tests/engines/test_werkzeug.py b/tests/engines/test_werkzeug.py index c2572dd..b492cad 100644 --- a/tests/engines/test_werkzeug.py +++ b/tests/engines/test_werkzeug.py @@ -1,4 +1,6 @@ import pytest +from scim2_models import BulkOperation +from scim2_models import BulkRequest from scim2_models import PatchOp from scim2_models import PatchOperation from scim2_models import ResponseParameters @@ -77,6 +79,21 @@ def test_werkzeug_engine(scim_client): with pytest.raises(SCIMException): scim_client.query(User, response_user.id) + # Bulk operations are not implemented yet in scim2-server + with pytest.raises(SCIMException): + scim_client.bulk( + BulkRequest[User]( + operations=[ + BulkOperation[User]( + method="POST", + path="/Users", + bulk_id="qwerty", + data=User(user_name="Alice"), + ) + ] + ) + ) + def test_werkzeug_query_with_attributes(scim_client): """List query parameters like attributes are correctly serialized in the query string.""" diff --git a/tests/test_bulk.py b/tests/test_bulk.py new file mode 100644 index 0000000..114bdac --- /dev/null +++ b/tests/test_bulk.py @@ -0,0 +1,218 @@ +import pytest +from scim2_models import BulkOperation +from scim2_models import BulkRequest +from scim2_models import BulkResponse +from scim2_models import Error +from scim2_models import Group +from scim2_models import GroupMember +from scim2_models import User + +from scim2_client import RequestNetworkException + + +def test_bulk_request(httpserver, sync_client): + """Test that a bulk request is posted and its response is validated.""" + httpserver.expect_request("/Bulk", method="POST").respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkResponse"], + "Operations": [ + { + "location": "https://example.com/v2/Users/92b725cd-9465-4e7d-8c16-01f8e146b87a", + "method": "POST", + "bulkId": "qwerty", + "version": 'W/"4weymrEsh5O6cAEK"', + "status": "201", + }, + { + "location": "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a", + "method": "POST", + "bulkId": "ytrewq", + "version": 'W/"lha5bbazU3fNvfe5"', + "status": "201", + }, + ], + }, + status=200, + ) + req = BulkRequest[User | Group]( + operations=[ + BulkOperation[User]( + method="POST", + path="/Users", + bulk_id="qwerty", + data=User(user_name="Alice"), + ), + BulkOperation[Group]( + method="POST", + path="/Groups", + bulk_id="ytrewq", + data=Group( + display_name="Tour Guides", + members=[GroupMember(type="User", value="bulkId:qwerty")], + ), + ), + ] + ) + + response = sync_client.bulk(req) + assert ( + response.operations[0].location + == "https://example.com/v2/Users/92b725cd-9465-4e7d-8c16-01f8e146b87a" + ) + assert ( + response.operations[1].location + == "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a" + ) + + +def test_bulk_request_payload(httpserver, sync_client): + """Test that the operation payloads are sent in a bulk request context.""" + httpserver.expect_request( + "/Bulk", + method="POST", + json={ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkRequest"], + "Operations": [ + { + "method": "POST", + "bulkId": "qwerty", + "path": "/Users", + "data": { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "Alice", + }, + } + ], + }, + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkResponse"], + "Operations": [], + }, + status=200, + ) + req = BulkRequest[User]( + operations=[ + BulkOperation[User]( + method="POST", + path="/Users", + bulk_id="qwerty", + data=User(user_name="Alice"), + ) + ] + ) + + assert isinstance(sync_client.bulk(req), BulkResponse) + + +def test_dont_check_response(httpserver, sync_client): + """Test the check_response_payload attribute.""" + httpserver.expect_request("/Bulk", method="POST").respond_with_json( + {"foo": "bar"}, status=200 + ) + req = BulkRequest[User]( + operations=[ + BulkOperation[User]( + method="POST", + path="/Users", + bulk_id="qwerty", + data=User(user_name="Alice"), + ), + ] + ) + + response = sync_client.bulk(req, check_response_payload=False) + assert response == {"foo": "bar"} + + +def test_dont_check_request_payload(httpserver, sync_client): + """Test the check_request_payload attribute.""" + httpserver.expect_request( + "/Bulk", + method="POST", + json={ + "operations": [ + { + "method": "POST", + "path": "/Users", + "data": { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "Alice", + }, + }, + ], + }, + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkResponse"], + "Operations": [ + { + "location": "https://example.com/v2/Users/92b725cd-9465-4e7d-8c16-01f8e146b87a", + "method": "POST", + "bulkId": "qwerty", + "version": 'W/"4weymrEsh5O6cAEK"', + "status": "201", + }, + ], + }, + status=200, + ) + req = { + "operations": [ + { + "method": "POST", + "path": "/Users", + "data": { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "Alice", + }, + }, + ], + } + + response = sync_client.bulk(req, check_request_payload=False) + assert isinstance(response, BulkResponse) + + +@pytest.mark.parametrize("code", [400, 401, 403, 404, 409, 413, 500, 501]) +def test_errors(httpserver, sync_client, code): + """Test the error cases defined in RFC7644 §3.7.3.""" + httpserver.expect_request("/Bulk", method="POST").respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": str(code), + "detail": f"{code} error", + }, + status=code, + ) + + response = sync_client.bulk(raise_scim_errors=False) + + assert response == Error( + schemas=["urn:ietf:params:scim:api:messages:2.0:Error"], + status=code, + detail=f"{code} error", + ) + + +def test_request_network_error(sync_client): + """Test that httpx2 exceptions are transformed in RequestNetworkException.""" + with pytest.raises( + RequestNetworkException, match="Network error happened during request" + ): + sync_client.bulk(url="http://invalid.test") + + +def test_no_operation(httpserver, sync_client): + """Test a bulk response carrying no operation.""" + httpserver.expect_request("/Bulk", method="POST").respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkResponse"], + "Operations": [], + }, + status=200, + ) + req = BulkRequest[User](operations=[]) + + response = sync_client.bulk(req) + assert response.operations == []