Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -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
---------------------

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ classifiers = [

requires-python = ">= 3.11"
dependencies = [
"scim2-models>=0.8.0",
"scim2-models>=0.10.1",
]

[project.optional-dependencies]
Expand Down
8 changes: 8 additions & 0 deletions scim2_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
118 changes: 118 additions & 0 deletions tests/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 7 additions & 7 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading