Skip to content

Commit dd21979

Browse files
authored
Merge pull request #161 from python-scim/cursor-based-pagination
Support for cursor based pagination
2 parents b6c3bfa + ef3743f commit dd21979

12 files changed

Lines changed: 433 additions & 4 deletions

‎doc/changelog.rst‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@ Changelog
66

77
Added
88
^^^^^
9+
- Support for :rfc:`RFC9865 <9865>`
910
- :meth:`Resource.replace <scim2_models.Resource.replace>` returns whether the replacement
1011
changes the resource, the order of multi-valued entries aside. A server can keep
1112
``meta.version`` and ``meta.lastModified`` when a PUT changes nothing.
1213

1314
Changed
1415
^^^^^^^
16+
- :meth:`SCIMException.from_error <scim2_models.SCIMException.from_error>` reconstructs
17+
:class:`~scim2_models.InvalidCursorException`, :class:`~scim2_models.ExpiredCursorException` and
18+
:class:`~scim2_models.InvalidCountException` from an :class:`~scim2_models.Error` carrying the
19+
matching ``scimType``, as :rfc:`RFC9865 §2.1 <9865#section-2.1>` defines them. They used to fall
20+
back to the base :class:`~scim2_models.SCIMException`.
1521
- :attr:`AttributeBinding.urn <scim2_models.AttributeBinding.urn>` and the error messages
1622
that quote it spell the attribute as the schema declares it, such as ``userName``, whatever
1723
case the path used.

‎scim2_models/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
from .attributes import MultiValuedComplexAttribute
2323
from .base import BaseModel
2424
from .context import Context
25+
from .exceptions import ExpiredCursorException
26+
from .exceptions import InvalidCountException
27+
from .exceptions import InvalidCursorException
2528
from .exceptions import InvalidFilterException
2629
from .exceptions import InvalidPathException
2730
from .exceptions import InvalidSyntaxException
@@ -74,6 +77,7 @@
7477
from .resources.service_provider_config import ChangePassword
7578
from .resources.service_provider_config import ETag
7679
from .resources.service_provider_config import Filter
80+
from .resources.service_provider_config import Pagination
7781
from .resources.service_provider_config import Patch
7882
from .resources.service_provider_config import ServiceProviderConfig
7983
from .resources.service_provider_config import Sort
@@ -120,13 +124,16 @@
120124
"Entitlement",
121125
"Error",
122126
"ExtensibleStringEnum",
127+
"ExpiredCursorException",
123128
"Extension",
124129
"External",
125130
"Filter",
126131
"Group",
127132
"GroupMember",
128133
"GroupMembership",
129134
"Im",
135+
"InvalidCountException",
136+
"InvalidCursorException",
130137
"InvalidFilterException",
131138
"InvalidPathException",
132139
"InvalidSyntaxException",
@@ -141,6 +148,7 @@
141148
"MultiValuedComplexAttribute",
142149
"Name",
143150
"NoTargetException",
151+
"Pagination",
144152
"Patch",
145153
"PatchOp",
146154
"PatchOperation",

‎scim2_models/exceptions.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,45 @@ class SensitiveException(SCIMException):
314314
)
315315

316316

317+
class InvalidCursorException(SCIMException):
318+
"""Cursor value is invalid.
319+
320+
Corresponds to scimType ``invalidCursor`` with HTTP status 400.
321+
322+
:rfc:`RFC 9865 Section 2.1 <9865#section-2.1>`
323+
"""
324+
325+
status = 400
326+
scim_type = "invalidCursor"
327+
_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."
328+
329+
330+
class ExpiredCursorException(SCIMException):
331+
"""Cursor has expired.
332+
333+
Corresponds to scimType ``expiredCursor`` with HTTP status 400.
334+
335+
:rfc:`RFC 9865 Section 2.3 <9865#section-2.3>`
336+
"""
337+
338+
status = 400
339+
scim_type = "expiredCursor"
340+
_default_detail = "Cursor has expired. Do not wait longer than service provider's cursorTimeout to request additional pages."
341+
342+
343+
class InvalidCountException(SCIMException):
344+
"""Count value is invalid.
345+
346+
Corresponds to scimType ``invalidCount`` with HTTP status 400.
347+
348+
:rfc:`RFC 9865 Section 2.4 <9865#section-2.4>`
349+
"""
350+
351+
status = 400
352+
scim_type = "invalidCount"
353+
_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."
354+
355+
317356
_SCIM_TYPE_TO_EXCEPTION: dict[str, type[SCIMException]] = {
318357
"invalidFilter": InvalidFilterException,
319358
"tooMany": TooManyException,
@@ -325,4 +364,7 @@ class SensitiveException(SCIMException):
325364
"invalidValue": InvalidValueException,
326365
"invalidVers": InvalidVersionException,
327366
"sensitive": SensitiveException,
367+
"invalidCursor": InvalidCursorException,
368+
"expiredCursor": ExpiredCursorException,
369+
"invalidCount": InvalidCountException,
328370
}

‎scim2_models/messages/list_response.py‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
import re
12
from typing import Any
23
from typing import Generic
34
from typing import Self
45

56
from pydantic import Field
67
from pydantic import ValidationInfo
78
from pydantic import ValidatorFunctionWrapHandler
9+
from pydantic import field_validator
810
from pydantic import model_validator
911
from pydantic_core import PydanticCustomError
1012

1113
from ..context import Context
14+
from ..exceptions import InvalidCursorException
1215
from ..resources.resource import AnyResource
1316
from ..urn import URN
1417
from .message import Message
@@ -53,6 +56,25 @@ class ListResponse(
5356
items_per_page: int | None = None
5457
"""The number of resources returned in a list response page."""
5558

59+
next_cursor: str | None = None
60+
"""A string value that can be used to retrieve the next page of list
61+
results."""
62+
63+
previous_cursor: str | None = None
64+
"""A string value that can be used to retrieve the previous page of list
65+
results."""
66+
67+
@field_validator("next_cursor", "previous_cursor")
68+
@classmethod
69+
def validate_cursor_chars(cls, value: str | None) -> str | None:
70+
"""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>`.
71+
72+
An empty cursor requests the first page, so a response cannot carry one.
73+
"""
74+
if value is not None and not re.fullmatch(r"[A-Za-z0-9\-._~]+", value):
75+
raise InvalidCursorException().as_pydantic_error()
76+
return value
77+
5678
resources: list[AnyResource] | None = Field(None, serialization_alias="Resources")
5779
"""A multi-valued list of complex objects containing the requested
5880
resources."""
@@ -68,6 +90,10 @@ def _check_results_number(
6890
6991
- 'totalResults' is required
7092
- 'resources' must be set if 'totalResults' is non-zero.
93+
94+
RFC9865 §2 makes 'totalResults' optional with cursor pagination.
95+
A response uses cursor pagination if the service provider supports it,
96+
or if the response carries a cursor.
7197
"""
7298
obj = handler(value)
7399
assert isinstance(obj, cls)
@@ -79,13 +105,23 @@ def _check_results_number(
79105
):
80106
return obj
81107

82-
if obj.total_results is None:
108+
config = info.context.get("scim_spc")
109+
cursor_pagination = bool(
110+
(config and config.pagination and config.pagination.cursor)
111+
or obj.next_cursor is not None
112+
or obj.previous_cursor is not None
113+
)
114+
if not cursor_pagination and obj.total_results is None:
83115
raise PydanticCustomError(
84116
"required_error",
85117
"Field 'totalResults' is required but value is missing or null",
86118
)
87119

88-
if obj.total_results > 0 and obj.resources is None:
120+
if (
121+
obj.total_results is not None
122+
and obj.total_results > 0
123+
and obj.resources is None
124+
):
89125
raise PydanticCustomError(
90126
"no_resource_error",
91127
"Field 'Resources' is missing or null but 'totalResults' is non-zero.",

‎scim2_models/messages/search_request.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from collections.abc import Iterable
23
from enum import StrEnum
34
from inspect import isclass
@@ -9,6 +10,7 @@
910
from ..annotations import CaseExact
1011
from ..annotations import Mutability
1112
from ..base import BaseModel
13+
from ..exceptions import InvalidCursorException
1214
from ..exceptions import InvalidFilterException
1315
from ..exceptions import InvalidPathException
1416
from ..path import Path
@@ -222,6 +224,21 @@ def _start_index_floor(cls, value: int | None) -> int | None:
222224
"""
223225
return None if value is None else max(1, value)
224226

227+
cursor: str | None = None
228+
"""A string value that can be used to retrieve the next page of results.
229+
The cursor value is defined in :rfc:`RFC9865 §2 <9865#section-2>`."""
230+
231+
@field_validator("cursor")
232+
@classmethod
233+
def validate_cursor_chars(cls, value: str | None) -> str | None:
234+
"""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>`.
235+
236+
unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
237+
"""
238+
if value is not None and not re.fullmatch(r"[A-Za-z0-9\-._~]*", value):
239+
raise InvalidCursorException().as_pydantic_error()
240+
return value
241+
225242
count: int | None = None
226243
"""An integer indicating the desired maximum number of query results per
227244
page."""

‎scim2_models/resources/service_provider_config.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
from typing import Annotated
22
from typing import Any
33

4+
from pydantic import field_validator
5+
46
from ..annotations import Mutability
57
from ..annotations import Required
68
from ..annotations import Returned
79
from ..annotations import Uniqueness
810
from ..attributes import ComplexAttribute
911
from ..attributes import ExtensibleStringEnum
12+
from ..exceptions import InvalidValueException
1013
from ..reference import External
1114
from ..reference import Reference
1215
from ..urn import URN
@@ -52,6 +55,41 @@ class ETag(ComplexAttribute):
5255
"""A Boolean value specifying whether or not the operation is supported."""
5356

5457

58+
class Pagination(ComplexAttribute):
59+
class DefaultPaginationMethod(ExtensibleStringEnum):
60+
cursor = "cursor"
61+
index = "index" # type: ignore[assignment]
62+
63+
cursor: Annotated[bool | None, Mutability.read_only, Required.true] = None
64+
"""A Boolean value specifying support of cursor-based pagination."""
65+
66+
index: Annotated[bool | None, Mutability.read_only, Required.true] = None
67+
"""A Boolean value specifying support of index-based pagination."""
68+
69+
default_pagination_method: Annotated[
70+
DefaultPaginationMethod | None, Mutability.read_only
71+
] = None
72+
"""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"."""
73+
74+
default_page_size: Annotated[int | None, Mutability.read_only] = None
75+
"""Positive integer value specifying the default number of results returned in a page when a count is not specified in the query."""
76+
77+
max_page_size: Annotated[int | None, Mutability.read_only] = None
78+
"""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."""
79+
80+
cursor_timeout: Annotated[int | None, Mutability.read_only] = None
81+
"""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."""
82+
83+
@field_validator("default_page_size", "max_page_size", "cursor_timeout")
84+
@classmethod
85+
def validate_positive_integers(cls, value: int | None) -> int | None:
86+
if value is not None and value <= 0:
87+
raise InvalidValueException(
88+
detail=f"{value} is not a positive integer"
89+
).as_pydantic_error()
90+
return value
91+
92+
5593
class AuthenticationScheme(ComplexAttribute):
5694
class Type(ExtensibleStringEnum):
5795
oauth = "oauth"
@@ -130,3 +168,6 @@ class ServiceProviderConfig(Resource[Any]):
130168
] = None
131169
"""A complex type that specifies supported authentication scheme
132170
properties."""
171+
172+
pagination: Annotated[Pagination | None, Mutability.read_only] = None
173+
"""A complex type that specifies pagination configuration options."""

‎tests/test_dynamic_schemas.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ def test_dynamic_service_provider_config_schema(load_sample):
9999
canonic_schema(sample)
100100

101101
schema["attributes"] = [
102-
attr for attr in schema["attributes"] if attr["name"] != "id"
102+
attr
103+
for attr in schema["attributes"]
104+
if attr["name"] not in ("id", "pagination")
103105
]
104106

105107
assert sample == schema

‎tests/test_errors.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from scim2_models.exceptions import ExpiredCursorException
2+
from scim2_models.exceptions import InvalidCountException
3+
from scim2_models.exceptions import InvalidCursorException
14
from scim2_models.exceptions import InvalidFilterException
25
from scim2_models.exceptions import InvalidPathException
36
from scim2_models.exceptions import InvalidSyntaxException
@@ -23,5 +26,8 @@ def test_predefined_errors():
2326
InvalidValueException(),
2427
InvalidVersionException(),
2528
SensitiveException(),
29+
InvalidCursorException(),
30+
ExpiredCursorException(),
31+
InvalidCountException(),
2632
):
2733
assert isinstance(exc.to_error(), Error)

0 commit comments

Comments
 (0)