Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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>`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sphinx RFC links are in the form of

:rfc:`label of the link <RFC number>`


Changed
^^^^^^^
Expand All @@ -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 <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
79 changes: 79 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,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",
Expand Down
8 changes: 8 additions & 0 deletions scim2_models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -116,13 +120,16 @@
"Entitlement",
"Error",
"ExtensibleStringEnum",
"ExpiredCursorException",
"Extension",
"External",
"Filter",
"Group",
"GroupMember",
"GroupMembership",
"Im",
"InvalidCountException",
"InvalidCursorException",
"InvalidFilterException",
"InvalidPathException",
"InvalidSyntaxException",
Expand All @@ -137,6 +144,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,
}
19 changes: 19 additions & 0 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 @@ -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."""

prev_cursor: str | None = None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's previous_cursor instead of prev_cursor.

https://www.rfc-editor.org/info/rfc9865/#name-response-attributes

"""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 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 Down
47 changes: 46 additions & 1 deletion scim2_models/messages/search_request.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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."""
Expand All @@ -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
23 changes: 23 additions & 0 deletions scim2_models/resources/service_provider_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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."""
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