From 5370d68f56ef6e692b7769b0373279741bdd174e Mon Sep 17 00:00:00 2001 From: brunelie Date: Thu, 25 Jun 2026 10:02:17 +0200 Subject: [PATCH 01/24] feat: add pagination attributes and configuration --- ...schema-service_provider_configuration.json | 68 +++++++++++++++++++ scim2_models/__init__.py | 2 + scim2_models/messages/list_response.py | 8 +++ scim2_models/messages/search_request.py | 4 ++ .../resources/service_provider_config.py | 20 ++++++ 5 files changed, 102 insertions(+) diff --git a/samples/rfc7643-8.7.2-schema-service_provider_configuration.json b/samples/rfc7643-8.7.2-schema-service_provider_configuration.json index dd5299cf..b7938bb6 100644 --- a/samples/rfc7643-8.7.2-schema-service_provider_configuration.json +++ b/samples/rfc7643-8.7.2-schema-service_provider_configuration.json @@ -108,6 +108,74 @@ } ] }, + { + "name": "pagination", + "type": "complex", + "multiValued": false, + "description": "none", + "required": true, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "cursor", + "type": "boolean", + "multiValued": false, + "description": "none", + "required": true, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "defaultPageSize", + "type": "integer", + "multiValued": false, + "description": "none", + "required": true, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "index", + "type": "boolean", + "multiValued": false, + "description": "none", + "required": true, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "maxPageSize", + "type": "integer", + "multiValued": false, + "description": "none", + "required": true, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "supported", + "type": "boolean", + "multiValued": false, + "description": "none", + "required": true, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + }, { "name": "changePassword", "type": "complex", diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index 62fab75f..581947af 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -72,6 +72,7 @@ from .resources.service_provider_config import ChangePassword from .resources.service_provider_config import ETag from .resources.service_provider_config import Filter +from .resources.service_provider_config import Pagination from .resources.service_provider_config import Patch from .resources.service_provider_config import ServiceProviderConfig from .resources.service_provider_config import Sort @@ -137,6 +138,7 @@ "MultiValuedComplexAttribute", "Name", "NoTargetException", + "Pagination", "Patch", "PatchOp", "PatchOperation", diff --git a/scim2_models/messages/list_response.py b/scim2_models/messages/list_response.py index 9afaeefa..7e52bf66 100644 --- a/scim2_models/messages/list_response.py +++ b/scim2_models/messages/list_response.py @@ -44,6 +44,14 @@ class ListResponse(Message, Generic[AnyResource], metaclass=_GenericMessageMetac items_per_page: int | None = None """The number of resources returned in a list response page.""" + next_cursor: str | None = None + """A string value that can be used to retrieve the next page of list + results.""" + + prev_cursor: str | None = None + """A string value that can be used to retrieve the previous page of list + results.""" + resources: list[AnyResource] | None = Field(None, serialization_alias="Resources") """A multi-valued list of complex objects containing the requested resources.""" diff --git a/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index a4580b59..329b6aa7 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -127,6 +127,10 @@ def start_index_floor(cls, value: int | None) -> int | None: """ return None if value is None else max(1, value) + cursor: str | None = None + """A string value that can be used to retrieve the next page of results. + The cursor value is defined in :rfc:`RFC9875 §2 <9875#section-2>`.""" + count: int | None = None """An integer indicating the desired maximum number of query results per page.""" diff --git a/scim2_models/resources/service_provider_config.py b/scim2_models/resources/service_provider_config.py index d834dc2b..866f1819 100644 --- a/scim2_models/resources/service_provider_config.py +++ b/scim2_models/resources/service_provider_config.py @@ -51,6 +51,23 @@ class ETag(ComplexAttribute): supported: Annotated[bool | None, Mutability.read_only, Required.true] = None """A Boolean value specifying whether or not the operation is supported.""" +class Pagination(ComplexAttribute): + supported: Annotated[bool | None, Mutability.read_only, Required.true] = None + """A Boolean value specifying whether or not the operation is supported.""" + + cursor: Annotated[bool | None, Mutability.read_only, Required.true] = None + """A Boolean value specifying whether or not the operation is supported.""" + + index: Annotated[bool | None, Mutability.read_only, Required.true] = None + """A Boolean value specifying whether or not the operation is supported.""" + + default_page_size: Annotated[int | None, Mutability.read_only, Required.true] = None + """An integer value specifying the default page size.""" + + max_page_size: Annotated[int | None, Mutability.read_only, Required.true] = None + """An integer value specifying the maximum page size.""" + + class AuthenticationScheme(ComplexAttribute): class Type(ExtensibleStringEnum): @@ -130,3 +147,6 @@ class ServiceProviderConfig(Resource[Any]): ] = None """A complex type that specifies supported authentication scheme properties.""" + + pagination: Annotated[Pagination | None, Mutability.read_only, Required.true] = None + """A complex type that specifies pagination configuration options.""" From f3692579adb6e91134575becf66e967481fce557 Mon Sep 17 00:00:00 2001 From: brunelie Date: Thu, 25 Jun 2026 10:14:13 +0200 Subject: [PATCH 02/24] feat: add pagination related exceptions fix: typo --- scim2_models/exceptions.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/scim2_models/exceptions.py b/scim2_models/exceptions.py index c374be27..1dbe1008 100644 --- a/scim2_models/exceptions.py +++ b/scim2_models/exceptions.py @@ -294,6 +294,41 @@ class SensitiveException(SCIMException): "information in a request URI" ) +class InvalidCursorException(SCIMException): + """Cursor value is invalid. + + Corresponds to scimType ``invalidCursor`` with HTTP status 400. + + :rfc:`RFC 9865 Section 2.1 <9865#section-2.1>` + """ + + status = 400 + scim_type = "invalidCursor" + _default_detail = "Cursor value is invalid. Cursor value SHOULD be empty to request the first page and set to the nextCursor or previousCursor value for subsequent queries." + +class ExpiredCursorException(SCIMException): + """Cursor has expired. + + Corresponds to scimType ``expiredCursor`` with HTTP status 400. + + :rfc:`RFC 9865 Section 2.3 <9865#section-2.3>` + """ + + status = 400 + scim_type = "expiredCursor" + _default_detail = "Cursor has expired. Do not wait longer than service provider's cursorTimeout to request additional pages." + +class InvalidCountException(SCIMException): + """Count value is invalid. + + Corresponds to scimType ``invalidCount`` with HTTP status 400. + + :rfc:`RFC 9865 Section 2.4 <9865#section-2.4>` + """ + + status = 400 + scim_type = "invalidCount" + _default_detail = "Count value is invalid. Count value must be between 0 and service provider's maxPageSize and must be equal to the count value of the initial query." _SCIM_TYPE_TO_EXCEPTION: dict[str, type[SCIMException]] = { "invalidFilter": InvalidFilterException, From 7a8c9a789596e0f71517acea9974d2b4c04c63cf Mon Sep 17 00:00:00 2001 From: brunelie Date: Thu, 25 Jun 2026 10:33:01 +0200 Subject: [PATCH 03/24] test: add pagination related exceptions to basic error tests --- scim2_models/__init__.py | 6 ++++++ tests/test_errors.py | 8 ++++++++ tests/test_exceptions.py | 20 ++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index 581947af..817f71dc 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -20,6 +20,9 @@ from .attributes import MultiValuedComplexAttribute from .base import BaseModel from .context import Context +from .exceptions import ExpiredCursorException +from .exceptions import InvalidCountException +from .exceptions import InvalidCursorException from .exceptions import InvalidFilterException from .exceptions import InvalidPathException from .exceptions import InvalidSyntaxException @@ -117,6 +120,7 @@ "Entitlement", "Error", "ExtensibleStringEnum", + "ExpiredCursorException", "Extension", "External", "Filter", @@ -124,6 +128,8 @@ "GroupMember", "GroupMembership", "Im", + "InvalidCountException", + "InvalidCursorException", "InvalidFilterException", "InvalidPathException", "InvalidSyntaxException", diff --git a/tests/test_errors.py b/tests/test_errors.py index 9f4dc42e..45d5e13d 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,3 +1,8 @@ +import warnings + +from scim2_models.exceptions import ExpiredCursorException +from scim2_models.exceptions import InvalidCountException +from scim2_models.exceptions import InvalidCursorException from scim2_models.exceptions import InvalidFilterException from scim2_models.exceptions import InvalidPathException from scim2_models.exceptions import InvalidSyntaxException @@ -23,5 +28,8 @@ def test_predefined_errors(): InvalidValueException(), InvalidVersionException(), SensitiveException(), + InvalidCursorException(), + ExpiredCursorException(), + InvalidCountException(), ): assert isinstance(exc.to_error(), Error) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 2287da86..4464bd92 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -8,6 +8,9 @@ from scim2_models import Context from scim2_models import Error +from scim2_models import ExpiredCursorException +from scim2_models import InvalidCountException +from scim2_models import InvalidCursorException from scim2_models import InvalidFilterException from scim2_models import InvalidPathException from scim2_models import InvalidSyntaxException @@ -78,6 +81,23 @@ def test_too_many_exception(): assert exc.status == 400 assert exc.scim_type == "tooMany" +def test_invalid_cursor_exception(): + """InvalidCursorException has correct status and scim_type.""" + exc = InvalidCursorException() + assert exc.status == 400 + assert exc.scim_type == "invalidCursor" + +def test_expired_cursor_exception(): + """ExpiredCursorException has correct status and scim_type.""" + exc = ExpiredCursorException() + assert exc.status == 400 + assert exc.scim_type == "expiredCursor" + +def test_invalid_count_exception(): + """InvalidCountException has correct status and scim_type.""" + exc = InvalidCountException() + assert exc.status == 400 + assert exc.scim_type == "invalidCount" def test_uniqueness_exception(): """UniquenessException has status 409 and stores attribute/value.""" From 7e18fd44d20a23bb20694adbc917026b31eae2ff Mon Sep 17 00:00:00 2001 From: brunelie Date: Thu, 25 Jun 2026 11:00:40 +0200 Subject: [PATCH 04/24] test: tests for cursor attributes in list response and search request --- tests/test_list_response.py | 57 ++++++++++++++++++++++++++++++++++++ tests/test_search_request.py | 23 +++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/tests/test_list_response.py b/tests/test_list_response.py index 035a1c38..8fd12cdc 100644 --- a/tests/test_list_response.py +++ b/tests/test_list_response.py @@ -396,6 +396,63 @@ def test_model_dump_without_scim_context(): assert payload["resources"][0]["user_name"] == "user-name" +def test_cursor_pagination(): + payload = { + "totalResults": 3, + "itemsPerPage": 1, + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "nextCursor": "cursor-abc", + "prevCursor": "cursor-xyz", + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "user-1", + "userName": "bjensen", + } + ], + } + response = ListResponse[User].model_validate(payload) + assert response.next_cursor == "cursor-abc" + assert response.prev_cursor == "cursor-xyz" + dumped = response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + assert dumped["nextCursor"] == "cursor-abc" + assert dumped["prevCursor"] == "cursor-xyz" + + +def test_cursor_pagination_first_page(): + payload = { + "totalResults": 5, + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "nextCursor": "cursor-abc", + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "user-1", + "userName": "bjensen", + } + ], + } + response = ListResponse[User].model_validate(payload) + assert response.next_cursor == "cursor-abc" + assert response.prev_cursor is None + dumped = response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + assert "nextCursor" in dumped + assert "prevCursor" not in dumped + + + +def test_cursor_absent_when_none(): + response = ListResponse[User]( + total_results=1, + resources=[User(id="user-1", user_name="bjensen")], + ) + assert response.next_cursor is None + assert response.prev_cursor is None + dumped = response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + assert "nextCursor" not in dumped + assert "prevCursor" not in dumped + + def test_total_results_required(): """ListResponse.total_results is required.""" payload = { diff --git a/tests/test_search_request.py b/tests/test_search_request.py index 4e7891c5..3d3c70cc 100644 --- a/tests/test_search_request.py +++ b/tests/test_search_request.py @@ -232,6 +232,29 @@ def test_comma_separated_empty_string(): assert req.attributes == [] +def test_cursor_field(): + sr = SearchRequest(cursor="cursor-abc") + assert sr.cursor == "cursor-abc" + + +def test_cursor_model_validate(): + payload = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"], + "cursor": "cursor-xyz", + "count": 10, + } + sr = SearchRequest.model_validate(payload) + assert sr.cursor == "cursor-xyz" + assert sr.count == 10 + + +def test_cursor_with_count(): + """Count is valid alongside cursor per RFC 9875.""" + sr = SearchRequest(cursor="cursor-abc", count=25) + assert sr.cursor == "cursor-abc" + assert sr.count == 25 + + def test_search_request_empty_lists(): """Test that empty attribute lists are handled correctly.""" valid_data = { From 3b3a3e9deb2f1c73ed685f53a30b9e18f10facfd Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 29 Jun 2026 08:43:50 +0200 Subject: [PATCH 05/24] feat: character validation on cursors --- scim2_models/messages/list_response.py | 14 ++++++++++++++ scim2_models/messages/search_request.py | 19 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/scim2_models/messages/list_response.py b/scim2_models/messages/list_response.py index 7e52bf66..e534608c 100644 --- a/scim2_models/messages/list_response.py +++ b/scim2_models/messages/list_response.py @@ -1,14 +1,17 @@ +import re from typing import Any from typing import Generic from pydantic import Field from pydantic import ValidationInfo from pydantic import ValidatorFunctionWrapHandler +from pydantic import field_validator from pydantic import model_validator from pydantic_core import PydanticCustomError from typing_extensions import Self from ..context import Context +from ..exceptions import InvalidCursorException from ..resources.resource import AnyResource from ..urn import URN from .message import Message @@ -52,6 +55,17 @@ class ListResponse(Message, Generic[AnyResource], metaclass=_GenericMessageMetac """A string value that can be used to retrieve the previous page of list results.""" + @field_validator("next_cursor", "prev_cursor") + @classmethod + def validate_cursor_chars(cls, value: str | None) -> str | None: + """According to :rfc:`RFC9865 §2 <9865#section-2>`, cursor values may only contain unreserved characters as defined in :rfc:`RFC3986 §2.3 <3986#section-2.3>`.""" + + if value == "": + return None + if value is not None and not re.fullmatch(r"[A-Za-z0-9\-._~]*", value): + raise InvalidCursorException().as_pydantic_error() + return value + resources: list[AnyResource] | None = Field(None, serialization_alias="Resources") """A multi-valued list of complex objects containing the requested resources.""" diff --git a/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index 329b6aa7..d350a9fb 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -1,9 +1,11 @@ +import re from enum import Enum from typing import Any from typing import Generic from pydantic import field_validator +from ..exceptions import InvalidCursorException from ..exceptions import InvalidFilterException from ..exceptions import InvalidPathException from ..path import Path @@ -128,8 +130,21 @@ def start_index_floor(cls, value: int | None) -> int | None: return None if value is None else max(1, value) cursor: str | None = None - """A string value that can be used to retrieve the next page of results. - The cursor value is defined in :rfc:`RFC9875 §2 <9875#section-2>`.""" + """A string value that can be used to retrieve the next page of results. + The cursor value is defined in :rfc:`RFC9865 §2 <9865#section-2>`.""" + + @field_validator("cursor") + @classmethod + def validate_cursor_chars(cls, value: str | None) -> str | None: + """According to :rfc:`RFC9865 §2 <9865#section-2>`, cursor values may only contain unreserved characters as defined in :rfc:`RFC3986 §2.3 <3986#section-2.3>`. + + unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + """ + if value == "": + return None + if value is not None and not re.fullmatch(r"[A-Za-z0-9\-._~]*", value): + raise InvalidCursorException().as_pydantic_error() + return value count: int | None = None """An integer indicating the desired maximum number of query results per From 71223397aa51bdcfa9033a27f7f6beb3b0c7cc76 Mon Sep 17 00:00:00 2001 From: brunelie Date: Thu, 9 Jul 2026 08:59:11 +0200 Subject: [PATCH 06/24] feat: add default pagination method and timeout to pagination config --- ...schema-service_provider_configuration.json | 132 ++++++++++-------- .../resources/service_provider_config.py | 6 + 2 files changed, 83 insertions(+), 55 deletions(-) diff --git a/samples/rfc7643-8.7.2-schema-service_provider_configuration.json b/samples/rfc7643-8.7.2-schema-service_provider_configuration.json index b7938bb6..1679a1a7 100644 --- a/samples/rfc7643-8.7.2-schema-service_provider_configuration.json +++ b/samples/rfc7643-8.7.2-schema-service_provider_configuration.json @@ -119,61 +119,83 @@ "returned": "default", "uniqueness": "none", "subAttributes": [ - { - "name": "cursor", - "type": "boolean", - "multiValued": false, - "description": "none", - "required": true, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "defaultPageSize", - "type": "integer", - "multiValued": false, - "description": "none", - "required": true, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "index", - "type": "boolean", - "multiValued": false, - "description": "none", - "required": true, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "maxPageSize", - "type": "integer", - "multiValued": false, - "description": "none", - "required": true, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "supported", - "type": "boolean", - "multiValued": false, - "description": "none", - "required": true, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } + { + "name": "cursor", + "type": "boolean", + "multiValued": false, + "description": "none", + "required": true, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "cursorTimeout", + "type": "integer", + "multiValued": false, + "description": "none", + "required": true, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "defaultPageSize", + "type": "integer", + "multiValued": false, + "description": "none", + "required": true, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "defaultPaginationMethod", + "type": "string", + "multiValued": false, + "description": "none", + "required": true, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "index", + "type": "boolean", + "multiValued": false, + "description": "none", + "required": true, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "maxPageSize", + "type": "integer", + "multiValued": false, + "description": "none", + "required": true, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "supported", + "type": "boolean", + "multiValued": false, + "description": "none", + "required": true, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } ] }, { diff --git a/scim2_models/resources/service_provider_config.py b/scim2_models/resources/service_provider_config.py index 866f1819..30dc9e5d 100644 --- a/scim2_models/resources/service_provider_config.py +++ b/scim2_models/resources/service_provider_config.py @@ -61,12 +61,18 @@ class Pagination(ComplexAttribute): index: Annotated[bool | None, Mutability.read_only, Required.true] = None """A Boolean value specifying whether or not the operation is supported.""" + default_pagination_method: Annotated[str | None, Mutability.read_only, Required.true] = None + """A string value specifying the default pagination method.""" + default_page_size: Annotated[int | None, Mutability.read_only, Required.true] = None """An integer value specifying the default page size.""" max_page_size: Annotated[int | None, Mutability.read_only, Required.true] = None """An integer value specifying the maximum page size.""" + cursor_timeout: Annotated[int | None, Mutability.read_only, Required.true] = None + """An integer value specifying the cursor timeout in seconds.""" + class AuthenticationScheme(ComplexAttribute): From c86cf9e03e8699c2584750ada7e33615ccd61197 Mon Sep 17 00:00:00 2001 From: brunelie Date: Thu, 9 Jul 2026 09:26:09 +0200 Subject: [PATCH 07/24] fix: fix configuration requirements for pagination --- ...schema-service_provider_configuration.json | 21 +++++-------------- scim2_models/messages/list_response.py | 1 - .../resources/service_provider_config.py | 16 +++++++------- 3 files changed, 12 insertions(+), 26 deletions(-) diff --git a/samples/rfc7643-8.7.2-schema-service_provider_configuration.json b/samples/rfc7643-8.7.2-schema-service_provider_configuration.json index 1679a1a7..15f753f7 100644 --- a/samples/rfc7643-8.7.2-schema-service_provider_configuration.json +++ b/samples/rfc7643-8.7.2-schema-service_provider_configuration.json @@ -113,7 +113,7 @@ "type": "complex", "multiValued": false, "description": "none", - "required": true, + "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", @@ -135,7 +135,7 @@ "type": "integer", "multiValued": false, "description": "none", - "required": true, + "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", @@ -146,7 +146,7 @@ "type": "integer", "multiValued": false, "description": "none", - "required": true, + "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", @@ -157,7 +157,7 @@ "type": "string", "multiValued": false, "description": "none", - "required": true, + "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", @@ -179,18 +179,7 @@ "type": "integer", "multiValued": false, "description": "none", - "required": true, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "supported", - "type": "boolean", - "multiValued": false, - "description": "none", - "required": true, + "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", diff --git a/scim2_models/messages/list_response.py b/scim2_models/messages/list_response.py index e534608c..d20aee5e 100644 --- a/scim2_models/messages/list_response.py +++ b/scim2_models/messages/list_response.py @@ -59,7 +59,6 @@ class ListResponse(Message, Generic[AnyResource], metaclass=_GenericMessageMetac @classmethod def validate_cursor_chars(cls, value: str | None) -> str | None: """According to :rfc:`RFC9865 §2 <9865#section-2>`, cursor values may only contain unreserved characters as defined in :rfc:`RFC3986 §2.3 <3986#section-2.3>`.""" - if value == "": return None if value is not None and not re.fullmatch(r"[A-Za-z0-9\-._~]*", value): diff --git a/scim2_models/resources/service_provider_config.py b/scim2_models/resources/service_provider_config.py index 30dc9e5d..6d133318 100644 --- a/scim2_models/resources/service_provider_config.py +++ b/scim2_models/resources/service_provider_config.py @@ -51,26 +51,24 @@ class ETag(ComplexAttribute): supported: Annotated[bool | None, Mutability.read_only, Required.true] = None """A Boolean value specifying whether or not the operation is supported.""" -class Pagination(ComplexAttribute): - supported: Annotated[bool | None, Mutability.read_only, Required.true] = None - """A Boolean value specifying whether or not the operation is supported.""" +class Pagination(ComplexAttribute): cursor: Annotated[bool | None, Mutability.read_only, Required.true] = None """A Boolean value specifying whether or not the operation is supported.""" index: Annotated[bool | None, Mutability.read_only, Required.true] = None """A Boolean value specifying whether or not the operation is supported.""" - default_pagination_method: Annotated[str | None, Mutability.read_only, Required.true] = None + default_pagination_method: Annotated[str | None, Mutability.read_only] = None """A string value specifying the default pagination method.""" - default_page_size: Annotated[int | None, Mutability.read_only, Required.true] = None + default_page_size: Annotated[int | None, Mutability.read_only] = None """An integer value specifying the default page size.""" - max_page_size: Annotated[int | None, Mutability.read_only, Required.true] = None + max_page_size: Annotated[int | None, Mutability.read_only] = None """An integer value specifying the maximum page size.""" - cursor_timeout: Annotated[int | None, Mutability.read_only, Required.true] = None + cursor_timeout: Annotated[int | None, Mutability.read_only] = None """An integer value specifying the cursor timeout in seconds.""" @@ -153,6 +151,6 @@ class ServiceProviderConfig(Resource[Any]): ] = None """A complex type that specifies supported authentication scheme properties.""" - - pagination: Annotated[Pagination | None, Mutability.read_only, Required.true] = None + + pagination: Annotated[Pagination | None, Mutability.read_only] = None """A complex type that specifies pagination configuration options.""" From 8a0a70715696660bfe9b90f64cbc21b89827d748 Mon Sep 17 00:00:00 2001 From: brunelie Date: Thu, 9 Jul 2026 10:09:36 +0200 Subject: [PATCH 08/24] feat: start_index and cursor are mutually exclusive in a search request --- scim2_models/messages/search_request.py | 15 +++++++++++++-- tests/test_search_request.py | 11 +++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index d350a9fb..d62deec4 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -3,7 +3,11 @@ from typing import Any from typing import Generic +from pydantic import ValidationInfo from pydantic import field_validator +from pydantic import model_validator +from pydantic_core import PydanticCustomError +from typing_extensions import Self from ..exceptions import InvalidCursorException from ..exceptions import InvalidFilterException @@ -140,8 +144,6 @@ def validate_cursor_chars(cls, value: str | None) -> str | None: unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" """ - if value == "": - return None if value is not None and not re.fullmatch(r"[A-Za-z0-9\-._~]*", value): raise InvalidCursorException().as_pydantic_error() return value @@ -172,3 +174,12 @@ def stop_index_0(self) -> int | None: if self.start_index_0 is not None and self.count is not None else None ) + + @model_validator(mode="after") + def check_cursor_and_index(self, info: ValidationInfo) -> Self: + if self.cursor is not None and self.start_index is not None: + raise PydanticCustomError( + "index_and_cursor_error", + "'cursor' and 'start_index' are mutually exclusive", + ) + return self \ No newline at end of file diff --git a/tests/test_search_request.py b/tests/test_search_request.py index 3d3c70cc..e331877f 100644 --- a/tests/test_search_request.py +++ b/tests/test_search_request.py @@ -247,6 +247,17 @@ def test_cursor_model_validate(): assert sr.cursor == "cursor-xyz" assert sr.count == 10 +def test_cursor_with_start_index(): + """Cursor and start_index are mutually exclusive.""" + invalid_search = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"], + "cursor": "", + "count": 10, + "start_index": 1 + } + with pytest.raises(ValidationError): + SearchRequest.model_validate(invalid_search) + def test_cursor_with_count(): """Count is valid alongside cursor per RFC 9875.""" From 251b847a0ad4960b4e5d90425cb700034d4e7423 Mon Sep 17 00:00:00 2001 From: brunelie Date: Thu, 9 Jul 2026 10:36:33 +0200 Subject: [PATCH 09/24] feat: enforce index pagination as default if no pagination method in search request --- scim2_models/messages/search_request.py | 15 +++++++++++++++ tests/test_search_request.py | 14 ++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index d62deec4..7b42f9f7 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -4,6 +4,7 @@ from typing import Generic from pydantic import ValidationInfo +from pydantic import ValidatorFunctionWrapHandler from pydantic import field_validator from pydantic import model_validator from pydantic_core import PydanticCustomError @@ -175,6 +176,20 @@ def stop_index_0(self) -> int | None: else None ) + @model_validator(mode="wrap") + @classmethod + def default_start_index( + cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo + ) -> Self: + """Default to start_index 1 if no start_index or cursor is provided.""" + obj = handler(value) + assert isinstance(obj, cls) + + if obj.cursor is None and obj.start_index is None: + obj.start_index = 1 + + return obj + @model_validator(mode="after") def check_cursor_and_index(self, info: ValidationInfo) -> Self: if self.cursor is not None and self.start_index is not None: diff --git a/tests/test_search_request.py b/tests/test_search_request.py index e331877f..5c3daa85 100644 --- a/tests/test_search_request.py +++ b/tests/test_search_request.py @@ -80,6 +80,20 @@ def test_index_0_properties(): req = SearchRequest(start_index=1, count=10) assert req.start_index_0 == 0 assert req.stop_index_0 == 10 + assert not req.cursor + +def test_default_pagination(): + req = SearchRequest(count=10) + assert req.start_index == 1 + assert req.start_index_0 == 0 + assert req.stop_index_0 == 10 + +def test_pagination_does_not_default_if_cursor(): + req = SearchRequest(count=10, cursor="") + assert not req.start_index + assert req.cursor == "" + assert not req.start_index_0 + assert not req.stop_index_0 def test_search_request_valid_attributes(): From 8ee232dc9a0aa0b8eb589e4809ddcc9fced80efc Mon Sep 17 00:00:00 2001 From: brunelie Date: Thu, 9 Jul 2026 10:51:44 +0200 Subject: [PATCH 10/24] refactor: remove extraneous test --- tests/test_search_request.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/test_search_request.py b/tests/test_search_request.py index 5c3daa85..2a69abdc 100644 --- a/tests/test_search_request.py +++ b/tests/test_search_request.py @@ -87,6 +87,7 @@ def test_default_pagination(): assert req.start_index == 1 assert req.start_index_0 == 0 assert req.stop_index_0 == 10 + assert not req.cursor def test_pagination_does_not_default_if_cursor(): req = SearchRequest(count=10, cursor="") @@ -245,12 +246,6 @@ def test_comma_separated_empty_string(): req = SearchRequest.model_validate({"attributes": ""}) assert req.attributes == [] - -def test_cursor_field(): - sr = SearchRequest(cursor="cursor-abc") - assert sr.cursor == "cursor-abc" - - def test_cursor_model_validate(): payload = { "schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"], From 8ddb7b1720ce5f69cfb6b162b34a524ee671cfca Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 14 Sep 2026 09:00:38 +0200 Subject: [PATCH 11/24] test: fix doctest adding pagination to searchrequest --- scim2_models/messages/search_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index 7b42f9f7..62224607 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -40,7 +40,7 @@ class SearchRequest(Message, ResponseParameters[ResourceT], Generic[ResourceT]): ... count=100, ... ) >>> request.model_dump(scim_ctx=Context.SEARCH_REQUEST) - {'schemas': ['urn:ietf:params:scim:api:messages:2.0:SearchRequest'], 'filter': 'userName eq "bjensen"', 'sortBy': 'userName', 'count': 100} + {'schemas': ['urn:ietf:params:scim:api:messages:2.0:SearchRequest'], 'filter': 'userName eq "bjensen"', 'sortBy': 'userName', 'startIndex': 1, 'count': 100} """ __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:SearchRequest") From fb7212f6e63d2d7c541e6ac5868a9802d446a38e Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 14 Sep 2026 09:39:21 +0200 Subject: [PATCH 12/24] chore: code styling --- scim2_models/exceptions.py | 10 +++++++--- scim2_models/messages/search_request.py | 4 ++-- scim2_models/resources/service_provider_config.py | 1 - tests/test_errors.py | 2 -- tests/test_exceptions.py | 4 ++++ tests/test_list_response.py | 1 - tests/test_search_request.py | 8 ++++++-- 7 files changed, 19 insertions(+), 11 deletions(-) diff --git a/scim2_models/exceptions.py b/scim2_models/exceptions.py index 1dbe1008..3867054b 100644 --- a/scim2_models/exceptions.py +++ b/scim2_models/exceptions.py @@ -294,9 +294,10 @@ class SensitiveException(SCIMException): "information in a request URI" ) + class InvalidCursorException(SCIMException): """Cursor value is invalid. - + Corresponds to scimType ``invalidCursor`` with HTTP status 400. :rfc:`RFC 9865 Section 2.1 <9865#section-2.1>` @@ -306,9 +307,10 @@ class InvalidCursorException(SCIMException): scim_type = "invalidCursor" _default_detail = "Cursor value is invalid. Cursor value SHOULD be empty to request the first page and set to the nextCursor or previousCursor value for subsequent queries." + class ExpiredCursorException(SCIMException): """Cursor has expired. - + Corresponds to scimType ``expiredCursor`` with HTTP status 400. :rfc:`RFC 9865 Section 2.3 <9865#section-2.3>` @@ -318,9 +320,10 @@ class ExpiredCursorException(SCIMException): scim_type = "expiredCursor" _default_detail = "Cursor has expired. Do not wait longer than service provider's cursorTimeout to request additional pages." + class InvalidCountException(SCIMException): """Count value is invalid. - + Corresponds to scimType ``invalidCount`` with HTTP status 400. :rfc:`RFC 9865 Section 2.4 <9865#section-2.4>` @@ -330,6 +333,7 @@ class InvalidCountException(SCIMException): scim_type = "invalidCount" _default_detail = "Count value is invalid. Count value must be between 0 and service provider's maxPageSize and must be equal to the count value of the initial query." + _SCIM_TYPE_TO_EXCEPTION: dict[str, type[SCIMException]] = { "invalidFilter": InvalidFilterException, "tooMany": TooManyException, diff --git a/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index 62224607..57fc0044 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -189,7 +189,7 @@ def default_start_index( obj.start_index = 1 return obj - + @model_validator(mode="after") def check_cursor_and_index(self, info: ValidationInfo) -> Self: if self.cursor is not None and self.start_index is not None: @@ -197,4 +197,4 @@ def check_cursor_and_index(self, info: ValidationInfo) -> Self: "index_and_cursor_error", "'cursor' and 'start_index' are mutually exclusive", ) - return self \ No newline at end of file + return self diff --git a/scim2_models/resources/service_provider_config.py b/scim2_models/resources/service_provider_config.py index 6d133318..574f0c69 100644 --- a/scim2_models/resources/service_provider_config.py +++ b/scim2_models/resources/service_provider_config.py @@ -72,7 +72,6 @@ class Pagination(ComplexAttribute): """An integer value specifying the cursor timeout in seconds.""" - class AuthenticationScheme(ComplexAttribute): class Type(ExtensibleStringEnum): oauth = "oauth" diff --git a/tests/test_errors.py b/tests/test_errors.py index 45d5e13d..1b73dc7a 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,5 +1,3 @@ -import warnings - from scim2_models.exceptions import ExpiredCursorException from scim2_models.exceptions import InvalidCountException from scim2_models.exceptions import InvalidCursorException diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 4464bd92..f9f6e93b 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -81,24 +81,28 @@ def test_too_many_exception(): assert exc.status == 400 assert exc.scim_type == "tooMany" + def test_invalid_cursor_exception(): """InvalidCursorException has correct status and scim_type.""" exc = InvalidCursorException() assert exc.status == 400 assert exc.scim_type == "invalidCursor" + def test_expired_cursor_exception(): """ExpiredCursorException has correct status and scim_type.""" exc = ExpiredCursorException() assert exc.status == 400 assert exc.scim_type == "expiredCursor" + def test_invalid_count_exception(): """InvalidCountException has correct status and scim_type.""" exc = InvalidCountException() assert exc.status == 400 assert exc.scim_type == "invalidCount" + def test_uniqueness_exception(): """UniquenessException has status 409 and stores attribute/value.""" exc = UniquenessException(attribute="userName", value="john") diff --git a/tests/test_list_response.py b/tests/test_list_response.py index 8fd12cdc..4834b25a 100644 --- a/tests/test_list_response.py +++ b/tests/test_list_response.py @@ -440,7 +440,6 @@ def test_cursor_pagination_first_page(): assert "prevCursor" not in dumped - def test_cursor_absent_when_none(): response = ListResponse[User]( total_results=1, diff --git a/tests/test_search_request.py b/tests/test_search_request.py index 2a69abdc..d52cd798 100644 --- a/tests/test_search_request.py +++ b/tests/test_search_request.py @@ -82,6 +82,7 @@ def test_index_0_properties(): assert req.stop_index_0 == 10 assert not req.cursor + def test_default_pagination(): req = SearchRequest(count=10) assert req.start_index == 1 @@ -89,6 +90,7 @@ def test_default_pagination(): assert req.stop_index_0 == 10 assert not req.cursor + def test_pagination_does_not_default_if_cursor(): req = SearchRequest(count=10, cursor="") assert not req.start_index @@ -246,6 +248,7 @@ def test_comma_separated_empty_string(): req = SearchRequest.model_validate({"attributes": ""}) assert req.attributes == [] + def test_cursor_model_validate(): payload = { "schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"], @@ -256,17 +259,18 @@ def test_cursor_model_validate(): assert sr.cursor == "cursor-xyz" assert sr.count == 10 + def test_cursor_with_start_index(): """Cursor and start_index are mutually exclusive.""" invalid_search = { "schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"], "cursor": "", "count": 10, - "start_index": 1 + "start_index": 1, } with pytest.raises(ValidationError): SearchRequest.model_validate(invalid_search) - + def test_cursor_with_count(): """Count is valid alongside cursor per RFC 9875.""" From 13d14a7d879cbd245d8b78934cbccdef86bf7b42 Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 14 Sep 2026 10:16:40 +0200 Subject: [PATCH 13/24] fix: cursor can be empty string --- scim2_models/messages/list_response.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/scim2_models/messages/list_response.py b/scim2_models/messages/list_response.py index d20aee5e..f7eacc3d 100644 --- a/scim2_models/messages/list_response.py +++ b/scim2_models/messages/list_response.py @@ -59,8 +59,6 @@ class ListResponse(Message, Generic[AnyResource], metaclass=_GenericMessageMetac @classmethod def validate_cursor_chars(cls, value: str | None) -> str | None: """According to :rfc:`RFC9865 §2 <9865#section-2>`, cursor values may only contain unreserved characters as defined in :rfc:`RFC3986 §2.3 <3986#section-2.3>`.""" - if value == "": - return None if value is not None and not re.fullmatch(r"[A-Za-z0-9\-._~]*", value): raise InvalidCursorException().as_pydantic_error() return value From f9718c278a9b0203f6a5d68debbfebdbce77f8f7 Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 14 Sep 2026 10:17:01 +0200 Subject: [PATCH 14/24] test: complete coverage on cursor character validation --- tests/test_list_response.py | 24 ++++++++++++++++++++++++ tests/test_search_request.py | 12 ++++++++++++ 2 files changed, 36 insertions(+) diff --git a/tests/test_list_response.py b/tests/test_list_response.py index 4834b25a..774f1795 100644 --- a/tests/test_list_response.py +++ b/tests/test_list_response.py @@ -10,6 +10,7 @@ from scim2_models import ResponseParameters from scim2_models import ServiceProviderConfig from scim2_models import User +from scim2_models.exceptions import InvalidCursorException from scim2_models.urn import URN @@ -440,6 +441,29 @@ def test_cursor_pagination_first_page(): assert "prevCursor" not in dumped +def test_invalid_cursor_exception(): + """An invalid cursor value raises InvalidCursorException.""" + payload = { + "totalResults": 1, + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "nextCursor": "not a valid cursor!", + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "user-1", + "userName": "bjensen", + } + ], + } + with pytest.raises(ValidationError) as exc_info: + ListResponse[User].model_validate(payload) + + error = exc_info.value.errors()[0] + assert error["type"] == "scim_invalidCursor" + assert error["ctx"]["scim_type"] == InvalidCursorException.scim_type + assert error["ctx"]["status"] == InvalidCursorException.status + + def test_cursor_absent_when_none(): response = ListResponse[User]( total_results=1, diff --git a/tests/test_search_request.py b/tests/test_search_request.py index d52cd798..5d1e2b41 100644 --- a/tests/test_search_request.py +++ b/tests/test_search_request.py @@ -4,6 +4,7 @@ from scim2_models import EnterpriseUser from scim2_models import Group from scim2_models import User +from scim2_models.exceptions import InvalidCursorException from scim2_models.messages.search_request import SearchRequest @@ -272,6 +273,17 @@ def test_cursor_with_start_index(): SearchRequest.model_validate(invalid_search) +def test_invalid_cursor_exception(): + """An invalid cursor value raises InvalidCursorException.""" + with pytest.raises(ValidationError) as exc_info: + SearchRequest(cursor="not a valid cursor!") + + error = exc_info.value.errors()[0] + assert error["type"] == "scim_invalidCursor" + assert error["ctx"]["scim_type"] == InvalidCursorException.scim_type + assert error["ctx"]["status"] == InvalidCursorException.status + + def test_cursor_with_count(): """Count is valid alongside cursor per RFC 9875.""" sr = SearchRequest(cursor="cursor-abc", count=25) From ad88353934ae249db2245f116043a3ef31190043 Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 14 Sep 2026 15:11:50 +0200 Subject: [PATCH 15/24] feat: complete scim exceptions with rfc 9865 exceptions --- doc/changelog.rst | 5 +++++ scim2_models/exceptions.py | 3 +++ tests/test_exceptions.py | 27 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/doc/changelog.rst b/doc/changelog.rst index f6a9d0ae..d6aecb2b 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -62,6 +62,11 @@ Changed when applied. It used to remove the entries equal to that ``value``, and to report no change when the ``value`` was a list or described an entry only in part. Set :attr:`~scim2_models.ScimPolicy.remove_value_as_filter` to keep reading it. +- :meth:`SCIMException.from_error ` reconstructs + :class:`~scim2_models.InvalidCursorException`, :class:`~scim2_models.ExpiredCursorException` and + :class:`~scim2_models.InvalidCountException` from an :class:`~scim2_models.Error` carrying the + matching ``scimType``, as :rfc:`RFC9865 §2.1 <9865#section-2.1>` defines them. They used to fall + back to the base :class:`~scim2_models.SCIMException`. Removed ^^^^^^^ diff --git a/scim2_models/exceptions.py b/scim2_models/exceptions.py index 3867054b..c504da5d 100644 --- a/scim2_models/exceptions.py +++ b/scim2_models/exceptions.py @@ -345,4 +345,7 @@ class InvalidCountException(SCIMException): "invalidValue": InvalidValueException, "invalidVers": InvalidVersionException, "sensitive": SensitiveException, + "invalidCursor": InvalidCursorException, + "expiredCursor": ExpiredCursorException, + "invalidCount": InvalidCountException, } diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index f9f6e93b..c1cba2eb 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -332,6 +332,9 @@ def test_all_exceptions_inherit_from_scim_exception(): InvalidValueException(), InvalidVersionException(), SensitiveException(), + InvalidCursorException(), + ExpiredCursorException(), + InvalidCountException(), ] for exc in exceptions: assert isinstance(exc, SCIMException) @@ -426,6 +429,30 @@ def test_from_error_sensitive(): assert exc.detail == "Sensitive data in URI" +def test_from_error_invalid_cursor(): + """from_error() creates InvalidCursorException from Error with scim_type invalidCursor.""" + error = Error(status=400, scim_type="invalidCursor", detail="Bad cursor") + exc = SCIMException.from_error(error) + assert isinstance(exc, InvalidCursorException) + assert exc.detail == "Bad cursor" + + +def test_from_error_expired_cursor(): + """from_error() creates ExpiredCursorException from Error with scim_type expiredCursor.""" + error = Error(status=400, scim_type="expiredCursor", detail="Cursor expired") + exc = SCIMException.from_error(error) + assert isinstance(exc, ExpiredCursorException) + assert exc.detail == "Cursor expired" + + +def test_from_error_invalid_count(): + """from_error() creates InvalidCountException from Error with scim_type invalidCount.""" + error = Error(status=400, scim_type="invalidCount", detail="Bad count") + exc = SCIMException.from_error(error) + assert isinstance(exc, InvalidCountException) + assert exc.detail == "Bad count" + + def test_from_error_unknown_scim_type(): """from_error() creates base SCIMException for unknown scim_type.""" error = Error(status=400, scim_type="unknownType", detail="Unknown error") From 75adf757264357c6fb5207979d969c8bb133a7e8 Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 14 Sep 2026 15:20:29 +0200 Subject: [PATCH 16/24] chore: update changelog --- doc/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/changelog.rst b/doc/changelog.rst index d6aecb2b..7f8e54a0 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -45,6 +45,7 @@ Added ``excludedAttributes`` spelled out one by one. A :class:`~scim2_models.SearchRequest` is one, so a server answering ``POST /.search`` passes the request it received. :issue:`141` - lark is a new dependency. +- Support for `RFC9865 <7644>` Changed ^^^^^^^ From 4d004f37fc8646ac966b327d2cc1337a4ee3b6b4 Mon Sep 17 00:00:00 2001 From: brunelie Date: Wed, 16 Sep 2026 09:27:02 +0200 Subject: [PATCH 17/24] fix: previousCursor instead of prevCursor per rfc --- scim2_models/messages/list_response.py | 4 ++-- tests/test_list_response.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/scim2_models/messages/list_response.py b/scim2_models/messages/list_response.py index f7eacc3d..bf7fc342 100644 --- a/scim2_models/messages/list_response.py +++ b/scim2_models/messages/list_response.py @@ -51,11 +51,11 @@ class ListResponse(Message, Generic[AnyResource], metaclass=_GenericMessageMetac """A string value that can be used to retrieve the next page of list results.""" - prev_cursor: str | None = None + previous_cursor: str | None = None """A string value that can be used to retrieve the previous page of list results.""" - @field_validator("next_cursor", "prev_cursor") + @field_validator("next_cursor", "previous_cursor") @classmethod def validate_cursor_chars(cls, value: str | None) -> str | None: """According to :rfc:`RFC9865 §2 <9865#section-2>`, cursor values may only contain unreserved characters as defined in :rfc:`RFC3986 §2.3 <3986#section-2.3>`.""" diff --git a/tests/test_list_response.py b/tests/test_list_response.py index 774f1795..052c4399 100644 --- a/tests/test_list_response.py +++ b/tests/test_list_response.py @@ -403,7 +403,7 @@ def test_cursor_pagination(): "itemsPerPage": 1, "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "nextCursor": "cursor-abc", - "prevCursor": "cursor-xyz", + "previousCursor": "cursor-xyz", "Resources": [ { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], @@ -414,10 +414,10 @@ def test_cursor_pagination(): } response = ListResponse[User].model_validate(payload) assert response.next_cursor == "cursor-abc" - assert response.prev_cursor == "cursor-xyz" + assert response.previous_cursor == "cursor-xyz" dumped = response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) assert dumped["nextCursor"] == "cursor-abc" - assert dumped["prevCursor"] == "cursor-xyz" + assert dumped["previousCursor"] == "cursor-xyz" def test_cursor_pagination_first_page(): @@ -435,10 +435,10 @@ def test_cursor_pagination_first_page(): } response = ListResponse[User].model_validate(payload) assert response.next_cursor == "cursor-abc" - assert response.prev_cursor is None + assert response.previous_cursor is None dumped = response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) assert "nextCursor" in dumped - assert "prevCursor" not in dumped + assert "previousCursor" not in dumped def test_invalid_cursor_exception(): @@ -470,10 +470,10 @@ def test_cursor_absent_when_none(): resources=[User(id="user-1", user_name="bjensen")], ) assert response.next_cursor is None - assert response.prev_cursor is None + assert response.previous_cursor is None dumped = response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) assert "nextCursor" not in dumped - assert "prevCursor" not in dumped + assert "previousCursor" not in dumped def test_total_results_required(): From 3067d5442827070e0b11ab4bda99cc864500c62d Mon Sep 17 00:00:00 2001 From: brunelie Date: Wed, 16 Sep 2026 09:29:13 +0200 Subject: [PATCH 18/24] docs: fix rfc reference in changelog --- doc/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index 7f8e54a0..3cb3b45c 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -45,7 +45,7 @@ Added ``excludedAttributes`` spelled out one by one. A :class:`~scim2_models.SearchRequest` is one, so a server answering ``POST /.search`` passes the request it received. :issue:`141` - lark is a new dependency. -- Support for `RFC9865 <7644>` +- Support for :rfc:`RFC9865 <9865>` Changed ^^^^^^^ From 0f66826a898e678ab965f0bb69ee196a7ce7ddf2 Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 21 Sep 2026 09:19:08 +0200 Subject: [PATCH 19/24] refactor: use ExtensibleStringEnum for default pagination in config --- ...43-8.7.2-schema-service_provider_configuration.json | 4 ++++ scim2_models/resources/service_provider_config.py | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/samples/rfc7643-8.7.2-schema-service_provider_configuration.json b/samples/rfc7643-8.7.2-schema-service_provider_configuration.json index 15f753f7..937e6418 100644 --- a/samples/rfc7643-8.7.2-schema-service_provider_configuration.json +++ b/samples/rfc7643-8.7.2-schema-service_provider_configuration.json @@ -158,6 +158,10 @@ "multiValued": false, "description": "none", "required": false, + "canonicalValues": [ + "cursor", + "index" + ], "caseExact": false, "mutability": "readOnly", "returned": "default", diff --git a/scim2_models/resources/service_provider_config.py b/scim2_models/resources/service_provider_config.py index 574f0c69..27b2f132 100644 --- a/scim2_models/resources/service_provider_config.py +++ b/scim2_models/resources/service_provider_config.py @@ -53,14 +53,20 @@ class ETag(ComplexAttribute): class Pagination(ComplexAttribute): + class DefaultPaginationMethod(ExtensibleStringEnum): + cursor = "cursor" + index = "index" + cursor: Annotated[bool | None, Mutability.read_only, Required.true] = None """A Boolean value specifying whether or not the operation is supported.""" index: Annotated[bool | None, Mutability.read_only, Required.true] = None """A Boolean value specifying whether or not the operation is supported.""" - default_pagination_method: Annotated[str | None, Mutability.read_only] = None - """A string value specifying the default pagination method.""" + default_pagination_method: Annotated[ + DefaultPaginationMethod | None, Mutability.read_only + ] = None + """A string value specifying the default pagination method""" default_page_size: Annotated[int | None, Mutability.read_only] = None """An integer value specifying the default page size.""" From 67712998764d0cae613d25d18d21a610031fd63b Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 21 Sep 2026 09:21:54 +0200 Subject: [PATCH 20/24] test: fix payload value for startindex in test search request --- tests/test_search_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_search_request.py b/tests/test_search_request.py index 5d1e2b41..c62966d4 100644 --- a/tests/test_search_request.py +++ b/tests/test_search_request.py @@ -267,7 +267,7 @@ def test_cursor_with_start_index(): "schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"], "cursor": "", "count": 10, - "start_index": 1, + "startIndex": 1, } with pytest.raises(ValidationError): SearchRequest.model_validate(invalid_search) From bb711bc617ce5d6e0857a1b9b88de7457b89da38 Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 21 Sep 2026 09:26:35 +0200 Subject: [PATCH 21/24] docs: use definitions from rfc 9865 in docstrings --- scim2_models/resources/service_provider_config.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scim2_models/resources/service_provider_config.py b/scim2_models/resources/service_provider_config.py index 27b2f132..ff18eed0 100644 --- a/scim2_models/resources/service_provider_config.py +++ b/scim2_models/resources/service_provider_config.py @@ -58,24 +58,24 @@ class DefaultPaginationMethod(ExtensibleStringEnum): index = "index" cursor: Annotated[bool | None, Mutability.read_only, Required.true] = None - """A Boolean value specifying whether or not the operation is supported.""" + """A Boolean value specifying support of cursor-based pagination.""" index: Annotated[bool | None, Mutability.read_only, Required.true] = None - """A Boolean value specifying whether or not the operation is supported.""" + """A Boolean value specifying support of index-based pagination.""" default_pagination_method: Annotated[ DefaultPaginationMethod | None, Mutability.read_only ] = None - """A string value specifying the default pagination method""" + """A string value specifying the type of pagination that the service provider defaults to when the client has not specified which method it wishes to use. Possible values are "cursor" and "index".""" default_page_size: Annotated[int | None, Mutability.read_only] = None - """An integer value specifying the default page size.""" + """Positive integer value specifying the default number of results returned in a page when a count is not specified in the query.""" max_page_size: Annotated[int | None, Mutability.read_only] = None - """An integer value specifying the maximum page size.""" + """Positive integer specifying the maximum number of results returned in a page regardless of what is specified for the count in a query. The maximum number of results returned may be further restricted by other criteria.""" cursor_timeout: Annotated[int | None, Mutability.read_only] = None - """An integer value specifying the cursor timeout in seconds.""" + """Positive integer specifying the minimum number of seconds that a cursor is valid between page requests. Clients waiting too long between cursor pagination requests may receive an invalid cursor error response. No value being specified may mean that there is no cursor timeout or that the cursor timeout is not a static duration.""" class AuthenticationScheme(ComplexAttribute): From ff8350dec83bce4efaeb33c1540b02f3941ebf68 Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 21 Sep 2026 11:12:28 +0200 Subject: [PATCH 22/24] feat: enforce positive integer for default_page_size, max_page_size and cursor_timeout --- .../resources/service_provider_config.py | 11 +++++++++++ tests/test_service_provider_configuration.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/scim2_models/resources/service_provider_config.py b/scim2_models/resources/service_provider_config.py index ff18eed0..497e7b26 100644 --- a/scim2_models/resources/service_provider_config.py +++ b/scim2_models/resources/service_provider_config.py @@ -1,12 +1,15 @@ from typing import Annotated from typing import Any +from pydantic import field_validator + from ..annotations import Mutability from ..annotations import Required from ..annotations import Returned from ..annotations import Uniqueness from ..attributes import ComplexAttribute from ..attributes import ExtensibleStringEnum +from ..exceptions import SCIMException from ..reference import External from ..reference import Reference from ..urn import URN @@ -77,6 +80,14 @@ class DefaultPaginationMethod(ExtensibleStringEnum): cursor_timeout: Annotated[int | None, Mutability.read_only] = None """Positive integer specifying the minimum number of seconds that a cursor is valid between page requests. Clients waiting too long between cursor pagination requests may receive an invalid cursor error response. No value being specified may mean that there is no cursor timeout or that the cursor timeout is not a static duration.""" + @field_validator("default_page_size", "max_page_size", "cursor_timeout") + @classmethod + def validate_positive_integers(cls, value: int | None) -> int | None: + if value is not None and value <= 0: + raise SCIMException( + path=str(value), detail=f"{str(value)!r} is not a positive integer" + ).as_pydantic_error() + return value class AuthenticationScheme(ComplexAttribute): class Type(ExtensibleStringEnum): diff --git a/tests/test_service_provider_configuration.py b/tests/test_service_provider_configuration.py index a4f3ac0c..cce76b02 100644 --- a/tests/test_service_provider_configuration.py +++ b/tests/test_service_provider_configuration.py @@ -1,6 +1,10 @@ import datetime +import pytest +from pydantic import ValidationError + from scim2_models import AuthenticationScheme +from scim2_models import Pagination from scim2_models import Reference from scim2_models import ServiceProviderConfig @@ -88,3 +92,18 @@ def test_authentication_scheme_type_accepts_unknown_schemes(): ) assert str(scheme.type) == "oauth2bearer" assert scheme.model_dump()["type"] == "oauth2bearer" + +@pytest.mark.parametrize("field", ["defaultPageSize", "maxPageSize", "cursorTimeout"]) +@pytest.mark.parametrize("value", [0, -1]) +def test_positive_integer_validator_rejects_invalid_values(field, value): + """Test that pagination integer fields reject zero and negative values.""" + with pytest.raises(ValidationError, match=f"'{value}' is not a positive integer"): + Pagination.model_validate({field: value}) + + +@pytest.mark.parametrize("field", ["defaultPageSize", "maxPageSize", "cursorTimeout"]) +@pytest.mark.parametrize("value", [1, 100, None]) +def test_positive_integer_validator_accepts_valid_values(field, value): + """Test that pagination integer fields accept positive integers and None.""" + pagination = Pagination.model_validate({field: value}) + assert pagination.model_dump().get(field) == value From 0cff967081d59eec28806ba316fb19d04bff9a29 Mon Sep 17 00:00:00 2001 From: brunelie Date: Mon, 21 Sep 2026 11:14:27 +0200 Subject: [PATCH 23/24] fix: scim2 models does not enforce default pagination values or methods --- scim2_models/messages/search_request.py | 30 +------------------------ tests/test_search_request.py | 21 ----------------- 2 files changed, 1 insertion(+), 50 deletions(-) diff --git a/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index 57fc0044..2c4897c2 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -3,12 +3,7 @@ from typing import Any from typing import Generic -from pydantic import ValidationInfo -from pydantic import ValidatorFunctionWrapHandler from pydantic import field_validator -from pydantic import model_validator -from pydantic_core import PydanticCustomError -from typing_extensions import Self from ..exceptions import InvalidCursorException from ..exceptions import InvalidFilterException @@ -40,7 +35,7 @@ class SearchRequest(Message, ResponseParameters[ResourceT], Generic[ResourceT]): ... count=100, ... ) >>> request.model_dump(scim_ctx=Context.SEARCH_REQUEST) - {'schemas': ['urn:ietf:params:scim:api:messages:2.0:SearchRequest'], 'filter': 'userName eq "bjensen"', 'sortBy': 'userName', 'startIndex': 1, 'count': 100} + {'schemas': ['urn:ietf:params:scim:api:messages:2.0:SearchRequest'], 'filter': 'userName eq "bjensen"', 'sortBy': 'userName', 'count': 100} """ __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:SearchRequest") @@ -175,26 +170,3 @@ def stop_index_0(self) -> int | None: if self.start_index_0 is not None and self.count is not None else None ) - - @model_validator(mode="wrap") - @classmethod - def default_start_index( - cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo - ) -> Self: - """Default to start_index 1 if no start_index or cursor is provided.""" - obj = handler(value) - assert isinstance(obj, cls) - - if obj.cursor is None and obj.start_index is None: - obj.start_index = 1 - - return obj - - @model_validator(mode="after") - def check_cursor_and_index(self, info: ValidationInfo) -> Self: - if self.cursor is not None and self.start_index is not None: - raise PydanticCustomError( - "index_and_cursor_error", - "'cursor' and 'start_index' are mutually exclusive", - ) - return self diff --git a/tests/test_search_request.py b/tests/test_search_request.py index c62966d4..653c1313 100644 --- a/tests/test_search_request.py +++ b/tests/test_search_request.py @@ -84,14 +84,6 @@ def test_index_0_properties(): assert not req.cursor -def test_default_pagination(): - req = SearchRequest(count=10) - assert req.start_index == 1 - assert req.start_index_0 == 0 - assert req.stop_index_0 == 10 - assert not req.cursor - - def test_pagination_does_not_default_if_cursor(): req = SearchRequest(count=10, cursor="") assert not req.start_index @@ -260,19 +252,6 @@ def test_cursor_model_validate(): assert sr.cursor == "cursor-xyz" assert sr.count == 10 - -def test_cursor_with_start_index(): - """Cursor and start_index are mutually exclusive.""" - invalid_search = { - "schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"], - "cursor": "", - "count": 10, - "startIndex": 1, - } - with pytest.raises(ValidationError): - SearchRequest.model_validate(invalid_search) - - def test_invalid_cursor_exception(): """An invalid cursor value raises InvalidCursorException.""" with pytest.raises(ValidationError) as exc_info: From 9531dfdf4fcf15538ab1ef3ff582e9b65f0ce598 Mon Sep 17 00:00:00 2001 From: brunelie Date: Tue, 22 Sep 2026 11:42:35 +0200 Subject: [PATCH 24/24] feat: totalResult not enforced if provider uses cursor based pagination --- scim2_models/messages/list_response.py | 6 +++-- tests/test_list_response.py | 31 ++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/scim2_models/messages/list_response.py b/scim2_models/messages/list_response.py index 6bd744fd..186d8ec0 100644 --- a/scim2_models/messages/list_response.py +++ b/scim2_models/messages/list_response.py @@ -98,13 +98,15 @@ def check_results_number( ): return obj - if obj.total_results is None: + config = info.context.get("scim_spc") + cursor_supported = bool(config and config.pagination and config.pagination.cursor) + if not cursor_supported and obj.total_results is None: raise PydanticCustomError( "required_error", "Field 'total_results' is required but value is missing or null", ) - if obj.total_results > 0 and obj.resources is None: + if obj.total_results is not None and obj.total_results > 0 and obj.resources is None: raise PydanticCustomError( "no_resource_error", "Field 'resources' is missing or null but 'total_results' is non-zero.", diff --git a/tests/test_list_response.py b/tests/test_list_response.py index 052c4399..d63185a4 100644 --- a/tests/test_list_response.py +++ b/tests/test_list_response.py @@ -5,6 +5,7 @@ from scim2_models import EnterpriseUser from scim2_models import Group from scim2_models import ListResponse +from scim2_models import Pagination from scim2_models import Resource from scim2_models import ResourceType from scim2_models import ResponseParameters @@ -475,9 +476,8 @@ def test_cursor_absent_when_none(): assert "nextCursor" not in dumped assert "previousCursor" not in dumped - def test_total_results_required(): - """ListResponse.total_results is required.""" + """ListResponse.total_results is required if the provider does not specify cursor based pagination support.""" payload = { "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "Resources": [ @@ -498,3 +498,30 @@ def test_total_results_required(): ListResponse[User].model_validate( payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE ) + +def test_total_results_not_required_for_cursor_pagination(): + """ListResponse.total_results is not required when the service provider supports cursor-based pagination.""" + spc = ServiceProviderConfig(pagination=Pagination(cursor=True)) + payload = { + "itemsPerPage": 1, + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "nextCursor": "cursor-abc", + "previousCursor": "cursor-xyz", + "Resources": [ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + ], + "userName": "bjensen@example.com", + "id": "foobar", + } + ], + } + response = ListResponse[User].model_validate( + payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE, scim_spc=spc + ) + assert response.next_cursor == "cursor-abc" + assert response.previous_cursor == "cursor-xyz" + dumped = response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + assert dumped["nextCursor"] == "cursor-abc" + assert dumped["previousCursor"] == "cursor-xyz"