diff --git a/doc/changelog.rst b/doc/changelog.rst index 86912b6..efccb15 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -62,6 +62,7 @@ Added :attr:`~scim2_models.BulkOperation.data` is checked as the single request it stands for: a creation for a POST, a patch for a PATCH. See :ref:`helpers-bulk`. :pr:`149` - lark is a new dependency. +- Support for :rfc:`RFC9865 <9865>` Changed ^^^^^^^ @@ -96,6 +97,11 @@ Changed - A PATCH reaching an extension attribute takes the extended resource type, as in ``PatchOp[User[EnterpriseUser]]``. ``PatchOp[User]`` used to carry such an operation to the endpoint, and now refuses a path its type parameter leaves out. +- :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/samples/rfc7643-8.7.2-schema-service_provider_configuration.json b/samples/rfc7643-8.7.2-schema-service_provider_configuration.json index dd5299c..937e641 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,89 @@ } ] }, + { + "name": "pagination", + "type": "complex", + "multiValued": false, + "description": "none", + "required": false, + "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": "cursorTimeout", + "type": "integer", + "multiValued": false, + "description": "none", + "required": false, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "defaultPageSize", + "type": "integer", + "multiValued": false, + "description": "none", + "required": false, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "defaultPaginationMethod", + "type": "string", + "multiValued": false, + "description": "none", + "required": false, + "canonicalValues": [ + "cursor", + "index" + ], + "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": false, + "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 51b22bc..7f03b92 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -22,6 +22,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 @@ -74,6 +77,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 @@ -120,6 +124,7 @@ "Entitlement", "Error", "ExtensibleStringEnum", + "ExpiredCursorException", "Extension", "External", "Filter", @@ -127,6 +132,8 @@ "GroupMember", "GroupMembership", "Im", + "InvalidCountException", + "InvalidCursorException", "InvalidFilterException", "InvalidPathException", "InvalidSyntaxException", @@ -141,6 +148,7 @@ "MultiValuedComplexAttribute", "Name", "NoTargetException", + "Pagination", "Patch", "PatchOp", "PatchOperation", diff --git a/scim2_models/exceptions.py b/scim2_models/exceptions.py index c374be2..c504da5 100644 --- a/scim2_models/exceptions.py +++ b/scim2_models/exceptions.py @@ -295,6 +295,45 @@ class SensitiveException(SCIMException): ) +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, "tooMany": TooManyException, @@ -306,4 +345,7 @@ class SensitiveException(SCIMException): "invalidValue": InvalidValueException, "invalidVers": InvalidVersionException, "sensitive": SensitiveException, + "invalidCursor": InvalidCursorException, + "expiredCursor": ExpiredCursorException, + "invalidCount": InvalidCountException, } diff --git a/scim2_models/messages/list_response.py b/scim2_models/messages/list_response.py index de73f1e..186d8ec 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 @@ -53,6 +56,22 @@ class ListResponse( 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.""" + + previous_cursor: str | None = None + """A string value that can be used to retrieve the previous page of list + results.""" + + @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>`.""" + 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.""" @@ -79,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/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index a4580b5..2c4897c 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 @@ -127,6 +129,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:`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 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 page.""" diff --git a/scim2_models/resources/service_provider_config.py b/scim2_models/resources/service_provider_config.py index d834dc2..497e7b2 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 @@ -52,6 +55,40 @@ class ETag(ComplexAttribute): """A Boolean value specifying whether or not the operation is supported.""" +class Pagination(ComplexAttribute): + class DefaultPaginationMethod(ExtensibleStringEnum): + cursor = "cursor" + index = "index" + + cursor: Annotated[bool | None, Mutability.read_only, Required.true] = None + """A Boolean value specifying support of cursor-based pagination.""" + + index: Annotated[bool | None, Mutability.read_only, Required.true] = None + """A Boolean value specifying support of index-based pagination.""" + + default_pagination_method: Annotated[ + DefaultPaginationMethod | None, Mutability.read_only + ] = None + """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 + """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 + """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 + """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): oauth = "oauth" @@ -130,3 +167,6 @@ class ServiceProviderConfig(Resource[Any]): ] = None """A complex type that specifies supported authentication scheme properties.""" + + pagination: Annotated[Pagination | None, Mutability.read_only] = None + """A complex type that specifies pagination configuration options.""" diff --git a/tests/test_errors.py b/tests/test_errors.py index 9f4dc42..1b73dc7 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,3 +1,6 @@ +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 +26,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 2287da8..c1cba2e 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 @@ -79,6 +82,27 @@ def test_too_many_exception(): 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") @@ -308,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) @@ -402,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") diff --git a/tests/test_list_response.py b/tests/test_list_response.py index 035a1c3..d63185a 100644 --- a/tests/test_list_response.py +++ b/tests/test_list_response.py @@ -5,11 +5,13 @@ 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 from scim2_models import ServiceProviderConfig from scim2_models import User +from scim2_models.exceptions import InvalidCursorException from scim2_models.urn import URN @@ -396,8 +398,86 @@ 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", + "previousCursor": "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.previous_cursor == "cursor-xyz" + dumped = response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + assert dumped["nextCursor"] == "cursor-abc" + assert dumped["previousCursor"] == "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.previous_cursor is None + dumped = response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + assert "nextCursor" in dumped + assert "previousCursor" 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, + resources=[User(id="user-1", user_name="bjensen")], + ) + assert response.next_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 "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": [ @@ -418,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" diff --git a/tests/test_search_request.py b/tests/test_search_request.py index 4e7891c..653c131 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 @@ -80,6 +81,15 @@ 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_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(): @@ -232,6 +242,34 @@ def test_comma_separated_empty_string(): assert req.attributes == [] +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_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) + 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 = { diff --git a/tests/test_service_provider_configuration.py b/tests/test_service_provider_configuration.py index a4f3ac0..cce76b0 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