Skip to content
Merged
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
1 change: 1 addition & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Added
another. Only the attributes the wanted state names take part in the comparison, so what a peer
maintains and the caller does not model survives the modification — which is what a PATCH
offers over a PUT. See :doc:`how-to/build-a-patch`. :issue:`104`
- Support for Bulk operations. :pr:`149`
- lark is a new dependency.

Changed
Expand Down
3 changes: 3 additions & 0 deletions doc/how-to/validate-and-serialize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ in a request context, responses are validated and serialized in the matching res
* - ``POST /Users/.search``
- :attr:`~scim2_models.Context.SEARCH_REQUEST`
- :attr:`~scim2_models.Context.SEARCH_RESPONSE`
* - ``POST /Bulk``
- :attr:`~scim2_models.Context.BULK_REQUEST`
- :attr:`~scim2_models.Context.BULK_RESPONSE`

:attr:`~scim2_models.Context.DEFAULT` applies neither set of rules, and suits a resource held in
application state rather than exchanged over HTTP.
Expand Down
8 changes: 4 additions & 4 deletions doc/integrations/_examples/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@
from scim2_models import ComplexAttribute
from scim2_models import ETag
from scim2_models import Filter
from scim2_models import InvalidPathException
from scim2_models import Group
from scim2_models import InvalidPathException
from scim2_models import Meta
from scim2_models import Path
from scim2_models import Patch
from scim2_models import Path
from scim2_models import ResourceType
from scim2_models import ScimProvider
from scim2_models import ServiceProviderConfig
from scim2_models import SearchRequest
from scim2_models import ServiceProviderConfig
from scim2_models import Sort
from scim2_models import UniquenessException
from scim2_models import User
Expand Down Expand Up @@ -233,7 +233,7 @@ def to_scim_group(record):
models=[User],
config=ServiceProviderConfig(
patch=Patch(supported=True),
bulk=Bulk(supported=False, max_operations=0, max_payload_size=0),
bulk=Bulk(supported=True, max_operations=100, max_payload_size=1048576),
filter=Filter(supported=True, max_results=MAX_RESULTS),
change_password=ChangePassword(supported=False),
sort=Sort(supported=True),
Expand Down
6 changes: 6 additions & 0 deletions doc/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,12 @@ operation accepts or returns.
.. autoclass:: scim2_models.PatchResponseContext
:members:

.. autoclass:: scim2_models.BulkRequestContext
:members:

.. autoclass:: scim2_models.BulkResponseContext
:members:

.. autoclass:: scim2_models.CaseExact
:members:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"bulkId": "qwerty",
"data": {
"schemas": [
"urn:ietf:params:scim:api:messages:2.0:User"
"urn:ietf:params:scim:schemas:core:2.0:User"
],
"userName": "Alice"
}
Expand Down
4 changes: 4 additions & 0 deletions scim2_models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from .annotated import BulkRequestContext
from .annotated import BulkResponseContext
from .annotated import CreationRequestContext
from .annotated import CreationResponseContext
from .annotated import PatchRequestContext
Expand Down Expand Up @@ -102,7 +104,9 @@
"Bulk",
"BulkOperation",
"BulkRequest",
"BulkRequestContext",
"BulkResponse",
"BulkResponseContext",
"CaseExact",
"ChangePassword",
"ComplexAttribute",
Expand Down
20 changes: 20 additions & 0 deletions scim2_models/annotated.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,16 @@ def serialize_with_context(
Annotated[T, SCIMSerializer(Context.RESOURCE_PATCH_RESPONSE)],
type_params=(T,),
)
BulkRequestContext = TypeAliasType(
"BulkRequestContext",
Annotated[T, SCIMValidator(Context.BULK_REQUEST)],
type_params=(T,),
)
BulkResponseContext = TypeAliasType(
"BulkResponseContext",
Annotated[T, SCIMSerializer(Context.BULK_RESPONSE)],
type_params=(T,),
)
else:

class _RequestContextAlias:
Expand Down Expand Up @@ -226,3 +236,13 @@ class PatchResponseContext(_ResponseContextAlias):
"""Shortcut for ``Annotated[T, SCIMSerializer(Context.RESOURCE_PATCH_RESPONSE)]``."""

_ctx = Context.RESOURCE_PATCH_RESPONSE

class BulkRequestContext(_RequestContextAlias):
"""Shortcut for ``Annotated[T, SCIMValidator(Context.BULK_REQUEST)]``."""

_ctx = Context.BULK_REQUEST

class BulkResponseContext(_ResponseContextAlias):
"""Shortcut for ``Annotated[T, SCIMSerializer(Context.BULK_RESPONSE)]``."""

_ctx = Context.BULK_RESPONSE
37 changes: 35 additions & 2 deletions scim2_models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,9 @@ def enforce_scim_context(self, info: ValidationInfo) -> Self:
is_create_or_replace = scim_context in (
Context.RESOURCE_CREATION_REQUEST,
Context.RESOURCE_REPLACEMENT_REQUEST,
Context.BULK_REQUEST,
)
in_bulk = bool(info.context.get("scim_bulk")) if info.context else False
fields_set = self.model_fields_set

for field_name in self.__class__.model_fields:
Expand All @@ -444,7 +446,9 @@ def enforce_scim_context(self, info: ValidationInfo) -> Self:
if Context.is_request(scim_context):
if field_name in fields_set:
self._check_mutability(field_name, scim_context)
if is_create_or_replace:
if is_create_or_replace and not self._is_unresolved_bulk_reference(
field_name, in_bulk
):
self._check_necessity(field_name, value)
else:
# Must be response
Expand All @@ -455,6 +459,30 @@ def enforce_scim_context(self, info: ValidationInfo) -> Self:

return self

def _is_unresolved_bulk_reference(self, field_name: str, in_bulk: bool) -> bool:
"""Whether a required Reference field targets a resource still being created.

:rfc:`RFC7644 §3.7.2 <7644#section-3.7.2>` lets one bulk operation
reference a resource another operation in the same request is still
creating, via a ``"bulkId:"``-prefixed placeholder in the sibling
``value`` attribute (e.g. ``manager.value``). That reference's URI
can only be resolved once the target exists, so a required Reference
sub-attribute (e.g. ``manager.$ref``) isn't checked for necessity in
this one documented case.

A bulk operation's data carries the context of the single request it
stands for, so the bulk job it belongs to is known from the flag
BulkOperation sets while validating it.
"""
if not in_bulk:
return False

sibling_value = getattr(self, "value", None)
if not (isinstance(sibling_value, str) and sibling_value.startswith("bulkId:")):
return False

return _holds_reference(self.__class__, field_name)

def _raise_field_error(
self, field_name: str, error: PydanticCustomError
) -> NoReturn:
Expand Down Expand Up @@ -498,7 +526,11 @@ def _check_mutability(self, field_name: str, scim_context: Context) -> None:

elif (
scim_context
in (Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_REPLACEMENT_REQUEST)
in (
Context.RESOURCE_CREATION_REQUEST,
Context.RESOURCE_REPLACEMENT_REQUEST,
Context.BULK_REQUEST,
)
and mutability == Mutability.read_only
):
# Avoid re-triggering this validation by using __dict__
Expand Down Expand Up @@ -726,6 +758,7 @@ def _scim_request_serializer(
Context.RESOURCE_CREATION_REQUEST,
Context.RESOURCE_REPLACEMENT_REQUEST,
Context.RESOURCE_PATCH_REQUEST,
Context.BULK_REQUEST,
)
and mutability == Mutability.read_only
):
Expand Down
34 changes: 34 additions & 0 deletions scim2_models/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,38 @@ class Context(Enum):
- not dump attributes annotated with :attr:`~scim2_models.Returned.request` unless they are explicitly included.
"""

BULK_REQUEST = auto()
"""The bulk request context.

Should be used for clients building a payload for a bulk request,
and servers validating bulk request payloads.

This context applies to the bulk envelope: the request and the operations it
carries. Each operation's :attr:`~scim2_models.BulkOperation.data` is validated
in the context of the single request it is the payload of, as
:rfc:`RFC7644 §3.7 <7644#section-3.7>` defines it, so a POST data answers to
:attr:`RESOURCE_CREATION_REQUEST`, a PUT data to
:attr:`RESOURCE_REPLACEMENT_REQUEST` and a PATCH data to
:attr:`RESOURCE_PATCH_REQUEST`.

- When used for serialization, it will not dump attributes annotated with :attr:`~scim2_models.Mutability.read_only`.
- When used for validation, it will ignore attributes annotated with :attr:`~scim2_models.Mutability.read_only` and raise a :class:`~pydantic_core.ValidationError` when attributes annotated with :attr:`Required.true <scim2_models.Required.true>` are missing or null.
"""

BULK_RESPONSE = auto()
"""The bulk response context.

Should be used for servers building a payload for a bulk response,
and clients validating bulk response payloads.

- When used for validation, it will raise a :class:`~pydantic_core.ValidationError` when finding attributes annotated with :attr:`~scim2_models.Returned.never` or when attributes annotated with :attr:`~scim2_models.Returned.always` are missing or :data:`None`;
- When used for serialization, it will:
- always dump attributes annotated with :attr:`~scim2_models.Returned.always`;
- never dump attributes annotated with :attr:`~scim2_models.Returned.never`;
- dump attributes annotated with :attr:`~scim2_models.Returned.default` unless they are explicitly excluded;
- not dump attributes annotated with :attr:`~scim2_models.Returned.request` unless they are explicitly included.
"""

@classmethod
def is_request(cls, ctx: "Context") -> bool:
return ctx in (
Expand All @@ -176,6 +208,7 @@ def is_request(cls, ctx: "Context") -> bool:
cls.RESOURCE_REPLACEMENT_REQUEST,
cls.SEARCH_REQUEST,
cls.RESOURCE_PATCH_REQUEST,
cls.BULK_REQUEST,
)

@classmethod
Expand All @@ -186,4 +219,5 @@ def is_response(cls, ctx: "Context") -> bool:
cls.RESOURCE_REPLACEMENT_RESPONSE,
cls.SEARCH_RESPONSE,
cls.RESOURCE_PATCH_RESPONSE,
cls.BULK_RESPONSE,
)
Loading
Loading