diff --git a/doc/changelog.rst b/doc/changelog.rst index 2feb017..a4aad00 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,21 @@ Changelog ========= +[Unreleased] +------------ + +Added +^^^^^ +- Cursor-based pagination (:rfc:`9865`). Pass ``cursor`` in :class:`~scim2_models.SearchRequest` + and read the next cursor in :attr:`~scim2_models.ListResponse.next_cursor`. + When the server advertises cursor pagination, a response without ``totalResults`` is accepted. + A response with an invalid ``nextCursor`` or ``previousCursor`` raises + :class:`~scim2_client.ResponsePayloadValidationException`. + +Changed +^^^^^^^ +- scim2-models 0.10.1 is now the minimum supported version. + [0.10.0] - 2026-09-27 --------------------- diff --git a/pyproject.toml b/pyproject.toml index bc37339..b735ece 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ requires-python = ">= 3.11" dependencies = [ - "scim2-models>=0.8.0", + "scim2-models>=0.10.1", ] [project.optional-dependencies] diff --git a/scim2_client/client.py b/scim2_client/client.py index 83ab122..2cebd01 100644 --- a/scim2_client/client.py +++ b/scim2_client/client.py @@ -1345,6 +1345,10 @@ def query( Use :class:`~scim2_models.SearchRequest` when listing resources, to also pass ``filter``, ``sortBy``, ``sortOrder``, ``startIndex`` and ``count`` (:rfc:`RFC 7644 §3.4.2 <7644#section-3.4.2>`). + Pass ``cursor`` instead of ``startIndex`` for cursor-based pagination + (:rfc:`RFC 9865 §2 <9865#section-2>`). An empty cursor requests the + first page. The response gives the cursor of the next page in + :attr:`~scim2_models.ListResponse.next_cursor`. :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. @@ -1813,6 +1817,10 @@ async def query( Use :class:`~scim2_models.SearchRequest` when listing resources, to also pass ``filter``, ``sortBy``, ``sortOrder``, ``startIndex`` and ``count`` (:rfc:`RFC 7644 §3.4.2 <7644#section-3.4.2>`). + Pass ``cursor`` instead of ``startIndex`` for cursor-based pagination + (:rfc:`RFC 9865 §2 <9865#section-2>`). An empty cursor requests the + first page. The response gives the cursor of the next page in + :attr:`~scim2_models.ListResponse.next_cursor`. :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. diff --git a/tests/test_query.py b/tests/test_query.py index 35de578..01981b6 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -2,10 +2,13 @@ import pytest from scim2_models import Error +from scim2_models import ExpiredCursorException from scim2_models import Group +from scim2_models import InvalidCursorException from scim2_models import InvalidValueException from scim2_models import ListResponse from scim2_models import Meta +from scim2_models import Pagination from scim2_models import Resource from scim2_models import ResponseParameters from scim2_models import SCIMException @@ -15,6 +18,8 @@ from scim2_models import UniquenessException from scim2_models import User +from scim2_client.engines.httpx2 import Client +from scim2_client.engines.httpx2 import SyncSCIMClient from scim2_client.errors import RequestNetworkException from scim2_client.errors import ResponsePayloadValidationException from scim2_client.errors import SCIMResponseException @@ -321,6 +326,119 @@ def test_user_with_invalid_id(sync_client): assert response == Error(detail="Resource unknown not found", status=404) +def test_cursor_pagination(httpserver, sync_client): + """Test that the cursor is sent in the query string and the next cursor is returned.""" + httpserver.expect_oneshot_request( + "/Users", query_string="cursor=&count=1" + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "itemsPerPage": 1, + "nextCursor": "VZUTiyhEQJ94IR", + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + } + ], + }, + status=200, + content_type="application/scim+json", + ) + + response = sync_client.query( + User, query_parameters=SearchRequest(cursor="", count=1) + ) + assert response.next_cursor == "VZUTiyhEQJ94IR" + assert response.total_results is None + assert response.resources[0].user_name == "bjensen@example.com" + + +def test_cursor_pagination_last_page(httpserver): + """Test that the last page of a cursor-only server needs neither a cursor nor totalResults.""" + httpserver.expect_oneshot_request( + "/Users", query_string="cursor=VZUTiyhEQJ94IR" + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "itemsPerPage": 1, + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + } + ], + }, + status=200, + content_type="application/scim+json", + ) + provider = ScimProvider( + models=[User, Group], + config=ServiceProviderConfig(pagination=Pagination(cursor=True, index=False)), + ) + + with Client(base_url=f"http://localhost:{httpserver.port}") as client: + scim_client = SyncSCIMClient(client, provider=provider) + response = scim_client.query( + User, query_parameters=SearchRequest(cursor="VZUTiyhEQJ94IR") + ) + + assert response.next_cursor is None + assert response.total_results is None + assert response.resources[0].user_name == "bjensen@example.com" + + +@pytest.mark.parametrize("field", ["nextCursor", "previousCursor"]) +@pytest.mark.parametrize("cursor", ["invalid%cursor", ""]) +def test_invalid_response_cursor(httpserver, sync_client, field, cursor): + """Test that a response with an invalid cursor raises ResponsePayloadValidationException.""" + httpserver.expect_oneshot_request("/Users").respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 1, + field: cursor, + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + } + ], + }, + status=200, + content_type="application/scim+json", + ) + + with pytest.raises(ResponsePayloadValidationException): + sync_client.query(User, query_parameters=SearchRequest(cursor="abc")) + + +@pytest.mark.parametrize( + "scim_type,exception", + [ + ("invalidCursor", InvalidCursorException), + ("expiredCursor", ExpiredCursorException), + ], +) +def test_cursor_rejected_by_server(httpserver, sync_client, scim_type, exception): + """Test that a cursor rejected by the server raises the matching exception.""" + httpserver.expect_oneshot_request("/Users").respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "scimType": scim_type, + "detail": "Cursor rejected", + "status": "400", + }, + status=400, + content_type="application/scim+json", + ) + + with pytest.raises(exception, match="Cursor rejected"): + sync_client.query(User, query_parameters=SearchRequest(cursor="abc")) + + def test_raise_scim_errors(sync_client): """Test that querying an user with an invalid id raises an exception.""" with pytest.raises( diff --git a/uv.lock b/uv.lock index d605e25..72caf17 100644 --- a/uv.lock +++ b/uv.lock @@ -14,12 +14,12 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P14D" [options.exclude-newer-package] -scim2-server = false -scim2-models = false +pytest-scim2-server = false scim2-cli = false scim2-client = false +scim2-models = false +scim2-server = false scim2-tester = false -pytest-scim2-server = false [[package]] name = "alabaster" @@ -1240,7 +1240,7 @@ doc = [ [package.metadata] requires-dist = [ { name = "httpx2", marker = "extra == 'httpx2'", specifier = ">=2.12" }, - { name = "scim2-models", specifier = ">=0.8.0" }, + { name = "scim2-models", specifier = ">=0.10.1" }, { name = "werkzeug", marker = "extra == 'werkzeug'", specifier = ">=3.1.3" }, ] provides-extras = ["httpx2", "werkzeug"] @@ -1270,15 +1270,15 @@ doc = [ [[package]] name = "scim2-models" -version = "0.9.0" +version = "0.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lark" }, { name = "pydantic", extra = ["email"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/25/a7722642f8420ecc0f9c4c271901b43208c263d64b1d7c0a1ed76cdfa661/scim2_models-0.9.0.tar.gz", hash = "sha256:a0b35178a2c03ca0b59300ce3c7724857fb9049dbf7f3b106b6696d32d0ae3cb", size = 96051, upload-time = "2026-09-27T20:00:15.438Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/eb/7a51b7b1f508a18a784f3427c26563505eda510e08bf06584440e9c93c14/scim2_models-0.10.1.tar.gz", hash = "sha256:f97d6fb5eecf0a7ef2b75f301a0b5370f9be2bfa9b072d3c566800460196f624", size = 101791, upload-time = "2026-09-30T16:19:18.053Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/f9/59cf15d896064e50e1a7b2f7570c4bc5fbe1071816d29f959456487ad617/scim2_models-0.9.0-py3-none-any.whl", hash = "sha256:934d11e7386edd2e0a682dfb279915c74d9f7b3027daf5333794a30bb7f813eb", size = 118555, upload-time = "2026-09-27T20:00:13.633Z" }, + { url = "https://files.pythonhosted.org/packages/97/57/7970e29757eba393a2fc8815e8fa3d0272cef73d397c532d9bbe99bbdba0/scim2_models-0.10.1-py3-none-any.whl", hash = "sha256:7ced87b5cd6ed2735721f17357d8934837f3ea5f9172751e9df4b66afa8baf1a", size = 124372, upload-time = "2026-09-30T16:19:16.666Z" }, ] [[package]]