diff --git a/doc/changelog.rst b/doc/changelog.rst index 3e12bb6..769974f 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -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 diff --git a/doc/how-to/validate-and-serialize.rst b/doc/how-to/validate-and-serialize.rst index 38f7b8b..ace64ab 100644 --- a/doc/how-to/validate-and-serialize.rst +++ b/doc/how-to/validate-and-serialize.rst @@ -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. diff --git a/doc/integrations/_examples/integrations.py b/doc/integrations/_examples/integrations.py index 4b6228b..8d75f58 100644 --- a/doc/integrations/_examples/integrations.py +++ b/doc/integrations/_examples/integrations.py @@ -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 @@ -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), diff --git a/doc/reference.rst b/doc/reference.rst index e6af2d3..f921d41 100644 --- a/doc/reference.rst +++ b/doc/reference.rst @@ -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: diff --git a/samples/rfc7644-3.7.3-bulk_request-multiple_operations.json b/samples/rfc7644-3.7.3-bulk_request-multiple_operations.json index b12af51..c76b88c 100644 --- a/samples/rfc7644-3.7.3-bulk_request-multiple_operations.json +++ b/samples/rfc7644-3.7.3-bulk_request-multiple_operations.json @@ -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" } diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index 62fab75..51b22bc 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -1,3 +1,5 @@ +from .annotated import BulkRequestContext +from .annotated import BulkResponseContext from .annotated import CreationRequestContext from .annotated import CreationResponseContext from .annotated import PatchRequestContext @@ -102,7 +104,9 @@ "Bulk", "BulkOperation", "BulkRequest", + "BulkRequestContext", "BulkResponse", + "BulkResponseContext", "CaseExact", "ChangePassword", "ComplexAttribute", diff --git a/scim2_models/annotated.py b/scim2_models/annotated.py index a46ab7f..7389cd0 100644 --- a/scim2_models/annotated.py +++ b/scim2_models/annotated.py @@ -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: @@ -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 diff --git a/scim2_models/base.py b/scim2_models/base.py index bccf61c..08a7934 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -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: @@ -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 @@ -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: @@ -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__ @@ -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 ): diff --git a/scim2_models/context.py b/scim2_models/context.py index 845a7c2..cc550d4 100644 --- a/scim2_models/context.py +++ b/scim2_models/context.py @@ -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 ` 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 ( @@ -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 @@ -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, ) diff --git a/scim2_models/messages/bulk.py b/scim2_models/messages/bulk.py index 34a5b32..e4b6e2e 100644 --- a/scim2_models/messages/bulk.py +++ b/scim2_models/messages/bulk.py @@ -1,24 +1,81 @@ from enum import Enum from typing import Annotated from typing import Any +from typing import ClassVar +from typing import Generic +from typing import TypeVar +from typing import Union +from typing import get_args +from typing import get_origin from pydantic import Field from pydantic import PlainSerializer +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 ..annotations import Required +from ..annotations import Returned from ..attributes import ComplexAttribute +from ..context import Context +from ..exceptions import InvalidValueException +from ..resources.resource import Resource from ..urn import URN +from ..utils import UNION_TYPES from ..utils import _int_to_str +from .error import Error from .message import Message +from .patch_op import PatchOp +ResourceT = TypeVar("ResourceT", bound=Resource[Any]) + + +def _require_type_parameter(cls: type, name: str) -> None: + """Refuse a bulk model used without the resource type its payloads carry. + + Parameterizing builds another class, carrying __origin__ and __args__, so + the bare name reaching this check means no parameter was given. Left alone, + the type variable falls back on its bound, and a valid payload fails deep + inside on an attribute that bound does not declare, blaming an attribute + for a missing parameter. + """ + if ( + cls.__name__ == name + and not hasattr(cls, "__origin__") + and not hasattr(cls, "__args__") + ): + raise TypeError( + f"{name} requires a type parameter. " + f"Use {name}[User] or {name}[User | Group] instead of {name}." + ) + + +class BulkOperation(ComplexAttribute, Generic[ResourceT]): + """One operation of a bulk job, as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`. + + ``data`` is validated in the context of the single request the operation + stands for, as :attr:`~scim2_models.Context.BULK_REQUEST` describes. + Parameterize the operation with the resource type it targets, e.g. + ``BulkOperation[User]``. + """ -class BulkOperation(ComplexAttribute): class Method(str, Enum): post = "POST" put = "PUT" patch = "PATCH" delete = "DELETE" - method: Method | None = None + _DATA_CONTEXTS: ClassVar[dict[Method, Context]] = { + Method.post: Context.RESOURCE_CREATION_REQUEST, + Method.put: Context.RESOURCE_REPLACEMENT_REQUEST, + Method.patch: Context.RESOURCE_PATCH_REQUEST, + } + """The single operation each method makes its data the payload of.""" + + method: Annotated[Method | None, Required.true] = None """The HTTP method of the current operation.""" bulk_id: str | None = None @@ -28,33 +85,148 @@ class Method(str, Enum): version: str | None = None """The current resource version.""" - path: str | None = None + path: Annotated[str | None, Returned.request] = None """The resource's relative path to the SCIM service provider's root.""" - data: Any | None = None + data: Annotated[ResourceT | PatchOp[ResourceT] | None, Returned.request] = None """The resource data as it would appear for a single SCIM POST, PUT, or PATCH operation.""" location: str | None = None """The resource endpoint URL.""" - response: Any | None = None + response: ResourceT | Error | None = None """The HTTP response body for the specified request operation.""" status: Annotated[int | None, PlainSerializer(_int_to_str)] = None """The HTTP response status code for the requested operation.""" + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + _require_type_parameter(cls, "BulkOperation") + return super().__new__(cls) + + @field_validator("data", mode="wrap") + @classmethod + def _validate_data_as_a_single_operation( + cls, + value: Any, + handler: ValidatorFunctionWrapHandler, + info: ValidationInfo, + ) -> Any: + """Validate data in the context of the operation it is the payload of. + + RFC 7644 §3.7: "data The resource data as it would appear for a single + SCIM POST, PUT, or PATCH operation." A payload answers to the rules of + the request it would be sent alone in, not to those of the bulk envelope + carrying it. The envelope keeps BULK_REQUEST, and a flag carries what + stays specific to a bulk job, such as a reference to a resource another + operation is still creating. + """ + context = info.context + if not context or context.get("scim") != Context.BULK_REQUEST: + return handler(value) + + method = info.data.get("method") + derived = cls._DATA_CONTEXTS.get(method) if method else None + if derived is None: + return handler(value) + + context["scim"] = derived + context["scim_bulk"] = True + try: + return handler(value) + finally: + context["scim"] = Context.BULK_REQUEST + del context["scim_bulk"] + + def __class_getitem__(cls, item: Any) -> Any: + """Turn ``BulkOperation[User | Group]`` into ``BulkOperation[User] | BulkOperation[Group]``. + + A bulk job's operations can each target a different resource type, but + substituting the union directly for ``ResourceT`` would build ``data``'s + ``PatchOp[User | Group]``, which :class:`PatchOp` rejects: a PATCH + always targets one concrete resource type. + """ + # Pydantic sometimes re-subscripts an already partially-parameterized + # model (e.g. while substituting BulkRequest's own type parameter) + # by passing a 1-tuple instead of the bare value. + param = item[0] if isinstance(item, tuple) and len(item) == 1 else item + + if not isinstance(param, TypeVar) and get_origin(param) in UNION_TYPES: + members = get_args(param) + return Union[tuple(cls[member] for member in members)] # type: ignore # noqa: UP007 + + return super().__class_getitem__(item) + + @model_validator(mode="after") + def validate_operation_requirements(self, info: ValidationInfo) -> Self: + """Validate operation requirements according to RFC 7644.""" + scim_ctx = info.context.get("scim") if info.context else None -class BulkRequest(Message): + if scim_ctx not in (Context.BULK_REQUEST, Context.BULK_RESPONSE): + return self + + if scim_ctx == Context.BULK_REQUEST: + # RFC 7644 Section 3.7: "path [...] REQUIRED in a request." + if self.path is None: + raise InvalidValueException( + detail="path is required for request operations" + ).as_pydantic_error() + + # RFC 7644 Section 3.7: "data The resource data as it would appear for a single SCIM POST, + # PUT, or PATCH operation. REQUIRED in a request when "method" is "POST", "PUT", or "PATCH"." + if self.data is None and self.method in ( + BulkOperation.Method.post, + BulkOperation.Method.put, + BulkOperation.Method.patch, + ): + raise InvalidValueException( + detail="data is required for POST, PUT, or PATCH request operations" + ).as_pydantic_error() + else: + # RFC 7644 Section 3.7: "location The resource endpoint URL. REQUIRED in a response, + # except in the event of a POST failure." + if self.location is None and not ( + self.method == BulkOperation.Method.post + and self.status is not None + and self.status >= 400 + ): + raise InvalidValueException( + detail="location is required for response" + ).as_pydantic_error() + + # RFC 7644 Section 3.7: "When indicating a response with an HTTP status + # other than a 200-series response, the response body MUST be included." + if ( + self.status is not None + and not 200 <= self.status < 300 + and (self.response is None or not isinstance(self.response, Error)) + ): + raise InvalidValueException( + detail="response parameter describing error is required" + ).as_pydantic_error() + + # RFC 7644 Section 3.7: "bulkId [...] REQUIRED when "method" is "POST"." + if self.method == BulkOperation.Method.post and self.bulk_id is None: + raise InvalidValueException( + detail="bulkId is required for POST operations" + ).as_pydantic_error() + + return self + + +class BulkRequest(Message, Generic[ResourceT]): """Bulk request as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`. The request groups independent SCIM operations. Its ``Operations`` field - keeps the SCIM capitalization during serialization: + keeps the SCIM capitalization during serialization. Parameterize it with + the resource type(s) the operations carry, e.g. ``BulkRequest[User | + Group]`` when a single bulk job creates both users and groups: - >>> from scim2_models import BulkOperation, BulkRequest, Context - >>> request = BulkRequest( + >>> from scim2_models import BulkOperation, BulkRequest, Context, User + >>> request = BulkRequest[User]( ... operations=[ - ... BulkOperation( + ... BulkOperation[User]( ... method="POST", ... bulk_id="create-user", ... path="/Users", @@ -62,15 +234,11 @@ class BulkRequest(Message): ... ) ... ] ... ) - >>> request.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST)["Operations"] - [{'method': 'POST', 'bulkId': 'create-user', 'path': '/Users', 'data': {'userName': 'bjensen'}}] + >>> request.model_dump(scim_ctx=Context.BULK_REQUEST)["Operations"] + [{'method': 'POST', 'bulkId': 'create-user', 'path': '/Users', 'data': {'schemas': ['urn:ietf:params:scim:schemas:core:2.0:User'], 'userName': 'bjensen'}}] scim2-models validates and serializes the message. Applying the operations it carries is left to the application. - - .. todo:: - - The models for Bulk operations are defined, but their behavior is not implemented nor tested yet. """ __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:BulkRequest") @@ -80,26 +248,53 @@ class BulkRequest(Message): will accept before the operation is terminated and an error response is returned.""" - operations: list[BulkOperation] | None = Field( + operations: Annotated[list[BulkOperation[ResourceT]] | None, Required.true] = Field( None, serialization_alias="Operations" ) """Defines operations within a bulk job.""" + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + _require_type_parameter(cls, "BulkRequest") + return super().__new__(cls) -class BulkResponse(Message): + +class BulkResponse(Message, Generic[ResourceT]): """Bulk response as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`. scim2-models validates and serializes the message. Building it from the - outcome of the operations is left to the application. - - .. todo:: - - The models for Bulk operations are defined, but their behavior is not implemented nor tested yet. + outcome of the operations is left to the application. Parameterize it + with the resource type(s) the operations carry, e.g. ``BulkResponse[User + | Group]``. """ __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:BulkResponse") - operations: list[BulkOperation] | None = Field( + operations: Annotated[list[BulkOperation[ResourceT]] | None, Required.true] = Field( None, serialization_alias="Operations" ) """Defines operations within a bulk job.""" + + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + _require_type_parameter(cls, "BulkResponse") + return super().__new__(cls) + + @model_validator(mode="after") + def check_operations(self, info: ValidationInfo) -> Self: + """Validate that a bulk response carries its operations. + + :rfc:`RFC7644 §3.7 <7644#section-3.7>` makes ``Operations`` required in + a bulk response as it is in a bulk request. A response context checks + what a peer returns rather than what it must send, so the necessity of + the attribute is stated here. + """ + scim_ctx = info.context.get("scim") if info.context else None + if scim_ctx != Context.BULK_RESPONSE: + return self + + if self.operations is None: + raise PydanticCustomError( + "required_error", + "Field 'operations' is required but value is missing or null", + ) + + return self diff --git a/scim2_models/resources/resource.py b/scim2_models/resources/resource.py index 82b830d..d0fd493 100644 --- a/scim2_models/resources/resource.py +++ b/scim2_models/resources/resource.py @@ -32,6 +32,7 @@ from ..base import BaseModel from ..context import Context from ..exceptions import InvalidPathException +from ..exceptions import InvalidValueException from ..path import Path from ..policy import ScimPolicy from ..policy import _policy @@ -397,6 +398,26 @@ def _validate_extension_schemas( return obj + @model_validator(mode="after") + def validate_resource_requirements(self, info: ValidationInfo) -> Self: + """Check the identifier constraints a service provider must meet. + + The ``id`` attribute is issued by the service provider and is read-only, + so these constraints only make sense on the payloads it emits. + """ + scim_ctx = info.context.get("scim") if info.context else None + if scim_ctx is None or not Context.is_response(scim_ctx): + return self + + # RFC 7643 Section 3.1: "The string "bulkId" is a reserved keyword and + # MUST NOT be used within any unique identifier value." + if self.id and "bulkId" in self.id: + raise InvalidValueException( + detail="'bulkId' is reserved for bulk operations" + ).as_pydantic_error() + + return self + @classmethod def to_schema(cls) -> "Schema": """Build a :class:`~scim2_models.Schema` from the current resource class.""" diff --git a/tests/test_bulk.py b/tests/test_bulk.py new file mode 100644 index 0000000..ed66424 --- /dev/null +++ b/tests/test_bulk.py @@ -0,0 +1,532 @@ +import pytest +from pydantic import ValidationError + +from scim2_models import Error +from scim2_models.base import Context +from scim2_models.messages.bulk import BulkOperation +from scim2_models.messages.bulk import BulkRequest +from scim2_models.messages.bulk import BulkResponse +from scim2_models.messages.patch_op import PatchOp +from scim2_models.messages.patch_op import PatchOperation +from scim2_models.resources.enterprise_user import EnterpriseUser +from scim2_models.resources.group import Group +from scim2_models.resources.group import GroupMember +from scim2_models.resources.user import User + + +def test_bulk_operation_delete(): + """A DELETE names its target with a path and carries no payload.""" + operation = BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.delete, + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + }, + scim_ctx=Context.BULK_REQUEST, + ) + assert operation.method == BulkOperation.Method.delete + assert operation.data is None + + +def test_operations_required_for_bulk_request(): + """A bulk request without operations describes no work at all.""" + with pytest.raises(ValidationError): + BulkRequest[User].model_validate( + {"operations": None}, context={"scim": Context.BULK_REQUEST} + ) + + +def test_operations_required_for_bulk_response(): + """Required.true is not consulted in a response context, so a validator of its own states it.""" + with pytest.raises(ValidationError): + BulkResponse[User].model_validate( + {"operations": None}, scim_ctx=Context.BULK_RESPONSE + ) + + +def test_bulkId_required_for_post_bulk_operations(): + """Test that bulkId is required for POST bulk operations. + + :rfc:`RFC7644` §3.7 <7644#section-3.7>: "bulkId [is] REQUIRED when "method" is "POST"." + """ + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "path": "/Users", + "data": User(user_name="John Doe"), + }, + context={"scim": Context.BULK_REQUEST}, + ) + with pytest.raises(ValidationError): + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": None, + "path": "/Users", + "data": User(user_name="John Doe"), + }, + context={"scim": Context.BULK_REQUEST}, + ) + + +def test_path_required_for_request_bulk_operations(): + """Test that path is required for request bulk operations. + + :rfc:`RFC7644` §3.7 <7644#section-3.7>: "path [...] REQUIRED in a request." + """ + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "path": "/Users", + "data": User(user_name="John Doe"), + }, + context={"scim": Context.BULK_REQUEST}, + ) + with pytest.raises(ValidationError): + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "path": None, + "data": User(user_name="John Doe"), + }, + context={"scim": Context.BULK_REQUEST}, + ) + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "path": None, + "location": "https://example.com/users/2819c223-7f76-453a-919d-413861904646", + "status": 201, + }, + context={"scim": Context.BULK_RESPONSE}, + ) + + +def test_data_required_for_post_put_patch_request_bulk_operations(): + """Test that data is required for POST, PUT, PATCH request bulk operations. + + :rfc:`RFC7644` §3.7 <7644#section-3.7>: "data The resource data as it would appear for a single SCIM POST, + PUT, or PATCH operation. REQUIRED in a request when "method" is "POST", "PUT", or "PATCH"." + """ + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "path": "/Users", + "data": User(user_name="John Doe"), + }, + context={"scim": Context.BULK_REQUEST}, + ) + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.patch, + "bulkId": "qwerty", + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": User(user_name="John Doe"), + }, + context={"scim": Context.BULK_REQUEST}, + ) + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.put, + "bulkId": "qwerty", + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": User(user_name="John Doe"), + }, + context={"scim": Context.BULK_REQUEST}, + ) + with pytest.raises(ValidationError): + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "path": "/Users", + "data": None, + }, + context={"scim": Context.BULK_REQUEST}, + ) + with pytest.raises(ValidationError): + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.patch, + "bulkId": "qwerty", + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": None, + }, + context={"scim": Context.BULK_REQUEST}, + ) + with pytest.raises(ValidationError): + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.put, + "bulkId": "qwerty", + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": None, + }, + context={"scim": Context.BULK_REQUEST}, + ) + + +def test_location_required_for_response_bulk_operations_except_post_errors(): + """Test that location is required for response bulk operations except POST errors. + + :rfc:`RFC7644` §3.7 <7644#section-3.7>: "location The resource endpoint URL. REQUIRED in a response, + except in the event of a POST failure." + """ + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "location": "https://example.com/users/2819c223-7f76-453a-919d-413861904646", + "status": 201, + }, + context={"scim": Context.BULK_RESPONSE}, + ) + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "location": None, + "status": 400, + "response": Error( + status=400, + ), + }, + context={"scim": Context.BULK_RESPONSE}, + ) + with pytest.raises(ValidationError): + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "location": None, + "status": 201, + }, + context={"scim": Context.BULK_RESPONSE}, + ) + with pytest.raises(ValidationError): + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.patch, + "bulkId": "qwerty", + "location": None, + "status": 400, + }, + context={"scim": Context.BULK_RESPONSE}, + ) + + +def test_method_required_for_bulk_operations(): + """Test that method is required for bulk operations.""" + with pytest.raises(ValidationError): + BulkOperation[User].model_validate( + { + "bulkId": "qwerty", + "path": "/Users", + "data": User(user_name="John Doe"), + }, + context={"scim": Context.BULK_REQUEST}, + ) + + +def test_error_response_required_in_response(): + """An operation that failed must say why, or the caller only learns that something went wrong.""" + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "status": 400, + "response": Error( + status=400, + ), + }, + context={"scim": Context.BULK_RESPONSE}, + ) + with pytest.raises(ValidationError): + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "status": 400, + }, + context={"scim": Context.BULK_RESPONSE}, + ) + + +def test_bulk_operation_with_group(): + """A bulk job carries any resource type, so an operation must not be tied to User.""" + group = Group( + display_name="Group 1", + members=[GroupMember(value="123", display="Test User")], + ) + BulkOperation[Group].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "path": "/Groups", + "data": group, + }, + context={"scim": Context.BULK_REQUEST}, + ) + + +def test_bulk_operation_with_patch_operation(): + """A PATCH operation carries a patch rather than a resource, which the data union must accept.""" + patch = PatchOp[User]( + operations=[ + PatchOperation[User]( + op=PatchOperation.Op.add, path="nickName", value="Babs" + ) + ] + ) + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.patch, + "bulkId": "qwerty", + "path": "/Users", + "data": patch, + }, + context={"scim": Context.BULK_REQUEST}, + ) + + +def test_bulk_request_with_multiple_resource_types(): + """A single bulk request can create both users and groups.""" + request = BulkRequest[User | Group].model_validate( + { + "operations": [ + { + "method": BulkOperation.Method.post, + "bulkId": "create-user", + "path": "/Users", + "data": {"userName": "bjensen"}, + }, + { + "method": BulkOperation.Method.post, + "bulkId": "create-group", + "path": "/Groups", + "data": {"displayName": "Tour Guides"}, + }, + ] + }, + context={"scim": Context.BULK_REQUEST}, + ) + + assert isinstance(request.operations[0].data, User) + assert request.operations[0].data.user_name == "bjensen" + assert isinstance(request.operations[1].data, Group) + assert request.operations[1].data.display_name == "Tour Guides" + + +def test_bulk_response_with_multiple_resource_types(): + """A single bulk response can carry both user and group results.""" + response = BulkResponse[User | Group].model_validate( + { + "operations": [ + { + "method": BulkOperation.Method.post, + "bulkId": "create-user", + "location": "https://example.com/v2/Users/92b725cd-9465-4d2f-9d49-d0d8aabb54d1", + "status": 201, + "response": { + "id": "92b725cd-9465-4d2f-9d49-d0d8aabb54d1", + "userName": "bjensen", + }, + }, + { + "method": BulkOperation.Method.post, + "bulkId": "create-group", + "location": "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a", + "status": 201, + "response": { + "id": "e9e30dba-f08f-4109-8486-d5c6a331660a", + "displayName": "Tour Guides", + "members": [ + { + "value": "92b725cd-9465-4d2f-9d49-d0d8aabb54d1", + "display": "bjensen", + } + ], + }, + }, + ] + }, + context={"scim": Context.BULK_RESPONSE}, + ) + + assert isinstance(response.operations[0].response, User) + assert response.operations[0].response.user_name == "bjensen" + assert isinstance(response.operations[1].response, Group) + assert response.operations[1].response.display_name == "Tour Guides" + + +def test_patch_operation_data_answers_to_the_patch_request_rules(): + """A bulk job must not be a way to send the patches a PATCH endpoint refuses.""" + + def patch(operations): + return { + "method": BulkOperation.Method.patch, + "bulkId": "qwerty", + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": operations, + }, + } + + with pytest.raises(ValidationError, match="value is required for add operations"): + BulkOperation[User].model_validate( + patch([{"op": "add", "path": "displayName"}]), + scim_ctx=Context.BULK_REQUEST, + ) + + with pytest.raises(ValidationError, match="Remove operation requires a path"): + BulkOperation[User].model_validate( + patch([{"op": "remove"}]), scim_ctx=Context.BULK_REQUEST + ) + + with pytest.raises(ValidationError, match="a remove operation carries no value"): + BulkOperation[User].model_validate( + patch([{"op": "remove", "path": "displayName", "value": "x"}]), + scim_ctx=Context.BULK_REQUEST, + ) + + operation = BulkOperation[User].model_validate( + patch([{"op": "add", "path": "displayName", "value": "Jane"}]), + scim_ctx=Context.BULK_REQUEST, + ) + assert isinstance(operation.data, PatchOp) + + +def test_patch_operation_data_reports_missing_operations_as_a_patch_would(): + """PatchOp reports a clearer error than the generic check the bulk envelope would apply.""" + with pytest.raises(ValidationError, match="operations attribute is required"): + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.patch, + "bulkId": "qwerty", + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": {"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]}, + }, + scim_ctx=Context.BULK_REQUEST, + ) + + +def test_post_operation_data_answers_to_the_creation_request_rules(): + """A POST data is the payload of a single creation request, so it needs what a creation needs.""" + with pytest.raises(ValidationError): + BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "path": "/Users", + "data": {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]}, + }, + scim_ctx=Context.BULK_REQUEST, + ) + + operation = BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "path": "/Users", + "data": { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen", + }, + }, + scim_ctx=Context.BULK_REQUEST, + ) + assert operation.data.user_name == "bjensen" + + +def test_operation_data_keeps_the_bulk_context_when_no_single_request_matches(): + """Neither a DELETE nor an unreadable method names a single request to borrow the rules from.""" + operation = BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.delete, + "path": "/Users/2819c223-7f76-453a-919d-413861904646", + "data": { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen", + }, + }, + scim_ctx=Context.BULK_REQUEST, + ) + assert operation.data.user_name == "bjensen" + + +def test_operation_envelope_keeps_the_bulk_context_after_its_data(): + """The data switches the context, and the envelope still needs the bulk rules afterwards.""" + with pytest.raises(ValidationError, match="Field 'method' is required"): + BulkOperation[User].model_validate( + { + "bulkId": "qwerty", + "path": "/Users", + "data": { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen", + }, + }, + scim_ctx=Context.BULK_REQUEST, + ) + + +def test_reference_to_a_resource_being_created_stays_tolerated_in_operation_data(): + """A creation request requires a resolved reference, but RFC7644 §3.7.2 allows a placeholder inside a bulk job.""" + + def operation(manager): + return { + "method": BulkOperation.Method.post, + "bulkId": "qwerty", + "path": "/Users", + "data": { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + ], + "userName": "bjensen", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { + "manager": manager + }, + }, + } + + BulkOperation[User[EnterpriseUser]].model_validate( + operation({"value": "bulkId:ytrewq"}), scim_ctx=Context.BULK_REQUEST + ) + + with pytest.raises(ValidationError): + BulkOperation[User[EnterpriseUser]].model_validate( + operation({"value": "2819c223-7f76-453a-919d-413861904646"}), + scim_ctx=Context.BULK_REQUEST, + ) + + with pytest.raises(ValidationError): + BulkOperation[User[EnterpriseUser]].model_validate( + operation({"value": "bulkId:ytrewq"}), + scim_ctx=Context.RESOURCE_CREATION_REQUEST, + ) + + +def test_bulk_models_require_a_type_parameter(): + """A bare model falls back on the type variable bound and blames a valid attribute for the missing parameter.""" + for model in (BulkRequest, BulkResponse, BulkOperation): + with pytest.raises(TypeError, match="requires a type parameter"): + model() + + +def test_bulk_rules_do_not_apply_outside_a_bulk_context(): + """A payload validated under another context is not part of a bulk job, and the bulk rules would reject it wrongly.""" + operation = BulkOperation[User].model_validate( + { + "method": BulkOperation.Method.put, + "location": "https://example.com/v2/Users/2819c223", + "status": "200", + }, + scim_ctx=Context.SEARCH_REQUEST, + ) + assert operation.path is None diff --git a/tests/test_model_serialization.py b/tests/test_model_serialization.py index c1d420d..f47988d 100644 --- a/tests/test_model_serialization.py +++ b/tests/test_model_serialization.py @@ -508,6 +508,7 @@ def test_invalid_excluded_attributes(): Context.RESOURCE_QUERY_RESPONSE, Context.RESOURCE_REPLACEMENT_RESPONSE, Context.SEARCH_RESPONSE, + Context.BULK_RESPONSE, ], ) def test_dump_response(context, ret_resource): diff --git a/tests/test_model_validation.py b/tests/test_model_validation.py index 0cec09f..c854bf8 100644 --- a/tests/test_model_validation.py +++ b/tests/test_model_validation.py @@ -41,6 +41,92 @@ class ReqResource(Resource): optional: Annotated[str | None, Required.false] = None +def test_validate_bulkId_not_in_resource_id(): + """Test that a response carrying the reserved keyword "bulkId" in a resource id is rejected. + + A client reading such an id cannot tell it apart from the transient + placeholder a bulk request uses to reference a resource being created. + + :rfc:`RFC7643` §3.1 <7643#section-3.1>: "The string 'bulkId' is a reserved keyword + and MUST NOT be used within any unique identifier value." + """ + with pytest.raises( + ValidationError, match="'bulkId' is reserved for bulk operations" + ): + Resource.model_validate( + { + "schemas": ["org:example:Resource"], + "id": "bulkId:foo", + }, + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + + +def test_validate_bulkId_anywhere_in_resource_id(): + """Test that the reserved keyword is rejected wherever it appears in a resource id. + + :rfc:`RFC7643` §3.1 <7643#section-3.1> forbids the keyword "within" an + identifier, not only as a prefix, so an opaque value cannot carry it either. + """ + with pytest.raises( + ValidationError, match="'bulkId' is reserved for bulk operations" + ): + Resource.model_validate( + { + "schemas": ["org:example:Resource"], + "id": "0e3f-bulkId-9a1c", + }, + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + + +def test_validate_bulkId_in_resource_id_is_case_sensitive(): + """Test that a resource id differing from the reserved keyword by its case is accepted. + + :rfc:`RFC7643` §3.1 <7643#section-3.1>: the id attribute characteristics are + "caseExact" as "true", so "bulkid" is another string than the reserved keyword. + """ + resource = Resource.model_validate( + { + "schemas": ["org:example:Resource"], + "id": "bulkid:foo", + }, + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + assert resource.id == "bulkid:foo" + + +def test_validate_bulkId_in_resource_id_accepted_without_context(): + """Test that a resource validated without any context keeps a reserved id. + + Validation outside of any context accepts every field, and the constraint + describes what a service provider may emit, not what a user builds in memory. + """ + resource = Resource.model_validate( + { + "schemas": ["org:example:Resource"], + "id": "bulkId:foo", + }, + ) + assert resource.id == "bulkId:foo" + + +def test_validate_bulkId_in_resource_id_accepted_in_requests(): + """Test that a request payload carrying a reserved id is not rejected. + + The id attribute is read-only, so a request context discards it before the + constraint applies: the client is never allowed to send that value anyway. + """ + resource = Resource.model_validate( + { + "schemas": ["org:example:Resource"], + "id": "bulkId:foo", + }, + scim_ctx=Context.RESOURCE_CREATION_REQUEST, + ) + assert resource.id is None + + def test_validate_default_mutability(): """Test query validation for resource creation request.""" assert MutResource.model_validate( @@ -606,6 +692,7 @@ def test_validate_default_response_returnability(): Context.RESOURCE_QUERY_RESPONSE, Context.RESOURCE_REPLACEMENT_RESPONSE, Context.SEARCH_RESPONSE, + Context.BULK_RESPONSE, ], ) def test_validate_response_returnability(context): diff --git a/tests/test_models.py b/tests/test_models.py index 8acb6d2..7b82a21 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -34,8 +34,8 @@ def _error_summary(exc: ValidationError) -> list[tuple[str, tuple]]: "service_provider_configuration": ServiceProviderConfig, "list_response": ListResponse[User | Group | Schema | ResourceType], "patch_op": PatchOp[User], - "bulk_request": BulkRequest, - "bulk_response": BulkResponse, + "bulk_request": BulkRequest[User | User[EnterpriseUser] | Group], + "bulk_response": BulkResponse[User | User[EnterpriseUser] | Group], "search_request": SearchRequest, "error": Error, } @@ -69,8 +69,8 @@ def _error_summary(exc: ValidationError) -> list[tuple[str, tuple]]: # An error answers any request. "error": Context.RESOURCE_QUERY_RESPONSE, # A bulk exchange is a POST on /Bulk. - "bulk_request": Context.RESOURCE_CREATION_REQUEST, - "bulk_response": Context.RESOURCE_CREATION_RESPONSE, + "bulk_request": Context.BULK_REQUEST, + "bulk_response": Context.BULK_RESPONSE, } # RFC7643 §8.2 and §8.3 illustrate every attribute at once. They carry a @@ -238,8 +238,8 @@ def test_everything_is_optional(): ServiceProviderConfig, ListResponse[User], PatchOp[User], - BulkRequest, - BulkResponse, + BulkRequest[User], + BulkResponse[User], SearchRequest, Error, ] @@ -259,8 +259,8 @@ def test_json_schema_generation(): ServiceProviderConfig, ListResponse[User], PatchOp[User], - BulkRequest, - BulkResponse, + BulkRequest[User], + BulkResponse[User], SearchRequest, Error, ]