Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5370d68
feat: add pagination attributes and configuration
funelie Jun 25, 2026
f369257
feat: add pagination related exceptions
funelie Jun 25, 2026
7a8c9a7
test: add pagination related exceptions to basic error tests
funelie Jun 25, 2026
7e18fd4
test: tests for cursor attributes in list response and search request
funelie Jun 25, 2026
3b3a3e9
feat: character validation on cursors
funelie Jun 29, 2026
7122339
feat: add default pagination method and timeout to pagination config
funelie Jul 9, 2026
c86cf9e
fix: fix configuration requirements for pagination
funelie Jul 9, 2026
8a0a707
feat: start_index and cursor are mutually exclusive in a search request
funelie Jul 9, 2026
251b847
feat: enforce index pagination as default if no pagination method in …
funelie Jul 9, 2026
8ee232d
refactor: remove extraneous test
funelie Jul 9, 2026
8ddb7b1
test: fix doctest adding pagination to searchrequest
funelie Sep 14, 2026
fb7212f
chore: code styling
funelie Sep 14, 2026
13d14a7
fix: cursor can be empty string
funelie Sep 14, 2026
f9718c2
test: complete coverage on cursor character validation
funelie Sep 14, 2026
ad88353
feat: complete scim exceptions with rfc 9865 exceptions
funelie Sep 14, 2026
75adf75
chore: update changelog
funelie Sep 14, 2026
4d004f3
fix: previousCursor instead of prevCursor per rfc
funelie Sep 16, 2026
3067d54
docs: fix rfc reference in changelog
funelie Sep 16, 2026
3cc7546
Merge branch 'main' into cursor-based-pagination
azmeuk Sep 16, 2026
0f66826
refactor: use ExtensibleStringEnum for default pagination in config
funelie Sep 21, 2026
6771299
test: fix payload value for startindex in test search request
funelie Sep 21, 2026
bb711bc
docs: use definitions from rfc 9865 in docstrings
funelie Sep 21, 2026
ff8350d
feat: enforce positive integer for default_page_size, max_page_size a…
funelie Sep 21, 2026
0cff967
fix: scim2 models does not enforce default pagination values or methods
funelie Sep 21, 2026
9531dfd
feat: totalResult not enforced if provider uses cursor based pagination
funelie Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^
Expand Down Expand Up @@ -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 <scim2_models.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
^^^^^^^
Expand Down
83 changes: 83 additions & 0 deletions samples/rfc7643-8.7.2-schema-service_provider_configuration.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions scim2_models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -120,13 +124,16 @@
"Entitlement",
"Error",
"ExtensibleStringEnum",
"ExpiredCursorException",
"Extension",
"External",
"Filter",
"Group",
"GroupMember",
"GroupMembership",
"Im",
"InvalidCountException",
"InvalidCursorException",
"InvalidFilterException",
"InvalidPathException",
"InvalidSyntaxException",
Expand All @@ -141,6 +148,7 @@
"MultiValuedComplexAttribute",
"Name",
"NoTargetException",
"Pagination",
"Patch",
"PatchOp",
"PatchOperation",
Expand Down
42 changes: 42 additions & 0 deletions scim2_models/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -306,4 +345,7 @@ class SensitiveException(SCIMException):
"invalidValue": InvalidValueException,
"invalidVers": InvalidVersionException,
"sensitive": SensitiveException,
"invalidCursor": InvalidCursorException,
"expiredCursor": ExpiredCursorException,
"invalidCount": InvalidCountException,
}
25 changes: 23 additions & 2 deletions scim2_models/messages/list_response.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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.",
Expand Down
17 changes: 17 additions & 0 deletions scim2_models/messages/search_request.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""
Expand Down
40 changes: 40 additions & 0 deletions scim2_models/resources/service_provider_config.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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."""
6 changes: 6 additions & 0 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -23,5 +26,8 @@ def test_predefined_errors():
InvalidValueException(),
InvalidVersionException(),
SensitiveException(),
InvalidCursorException(),
ExpiredCursorException(),
InvalidCountException(),
):
assert isinstance(exc.to_error(), Error)
Loading
Loading