diff --git a/doc/changelog.rst b/doc/changelog.rst index f6a9d0a..3cb3b45 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 :rfc:`RFC9865 <9865>` Changed ^^^^^^^ @@ -62,6 +63,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/samples/rfc7643-8.7.2-schema-service_provider_configuration.json b/samples/rfc7643-8.7.2-schema-service_provider_configuration.json index dd5299c..15f753f 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,85 @@ } ] }, + { + "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, + "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 62fab75..817f71d 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 @@ -72,6 +75,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 @@ -116,6 +120,7 @@ "Entitlement", "Error", "ExtensibleStringEnum", + "ExpiredCursorException", "Extension", "External", "Filter", @@ -123,6 +128,8 @@ "GroupMember", "GroupMembership", "Im", + "InvalidCountException", + "InvalidCursorException", "InvalidFilterException", "InvalidPathException", "InvalidSyntaxException", @@ -137,6 +144,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 9afaeef..bf7fc34 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 @@ -44,6 +47,22 @@ 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.""" + + 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.""" diff --git a/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index a4580b5..57fc004 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -1,9 +1,16 @@ +import re from enum import Enum 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 from ..exceptions import InvalidPathException from ..path import Path @@ -33,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") @@ -127,6 +134,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.""" @@ -153,3 +175,26 @@ 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/scim2_models/resources/service_provider_config.py b/scim2_models/resources/service_provider_config.py index d834dc2..574f0c6 100644 --- a/scim2_models/resources/service_provider_config.py +++ b/scim2_models/resources/service_provider_config.py @@ -52,6 +52,26 @@ class ETag(ComplexAttribute): """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] = 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.""" + + 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] = None + """An integer value specifying the cursor timeout in seconds.""" + + class AuthenticationScheme(ComplexAttribute): class Type(ExtensibleStringEnum): oauth = "oauth" @@ -130,3 +150,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..052c439 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 @@ -396,6 +397,85 @@ 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.""" payload = { diff --git a/tests/test_search_request.py b/tests/test_search_request.py index 4e7891c..5d1e2b4 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,23 @@ 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 + 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 +250,47 @@ 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_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_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 = {