From a984336752dcf46cff54c3de1bef0111379cd880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Thu, 10 Sep 2026 23:26:37 +0200 Subject: [PATCH] feat: let a resource declare an extension required --- doc/changelog.rst | 6 +++ doc/how-to/define-custom-models.rst | 28 ++++++++++ scim2_models/resources/resource.py | 46 +++++++++++++---- scim2_models/resources/resource_type.py | 6 ++- tests/test_resource_extension.py | 68 +++++++++++++++++++++++++ tests/test_resource_type.py | 12 +++++ 6 files changed, 154 insertions(+), 12 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index 95fa90fc..f729b54d 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -26,6 +26,12 @@ Added against the first type declaring it, so ``sortBy`` answers on a root query too. - :meth:`Path.resolve ` resolves a path to the :class:`~scim2_models.AttributeBinding` it designates. +- An extension may be declared required, as in + ``User[Annotated[EnterpriseUser, Required.true]]``. A creation or a replacement request that + leaves it out is refused, and + :meth:`ResourceType.from_resource ` publishes the + necessity under ``schemaExtensions.required``, which :rfc:`RFC7643 §6 <7643#section-6>` + defines. See :doc:`how-to/define-custom-models`. :issue:`105` - lark is a new dependency. Changed diff --git a/doc/how-to/define-custom-models.rst b/doc/how-to/define-custom-models.rst index e01acf1e..9b44df55 100644 --- a/doc/how-to/define-custom-models.rst +++ b/doc/how-to/define-custom-models.rst @@ -130,6 +130,34 @@ the extension as the resource type parameter, then access its values through the >>> user.model_dump()["urn:example:schemas:extension:pet:2.0:User"]["petName"] 'Mochi' +An extension is optional unless the parameter says otherwise. Annotate it with +:attr:`Required.true ` for a resource type that declares the +extension required, as :rfc:`RFC7643 §6 <7643#section-6>` allows. A creation or a replacement +request that leaves the extension out is then refused: + +.. doctest:: + + >>> from typing import Annotated + >>> from pydantic import ValidationError + >>> from scim2_models import Context, Required, ResourceType + >>> PetOwnerUser = User[Annotated[PetOwner, Required.true]] + >>> try: + ... PetOwnerUser.model_validate( + ... {"schemas": [User.__schema__], "userName": "bjensen"}, + ... scim_ctx=Context.RESOURCE_CREATION_REQUEST, + ... ) + ... except ValidationError as exc: + ... print(exc.errors()[0]["msg"]) + Field 'PetOwner' is required but value is missing or null + +:meth:`ResourceType.from_resource ` publishes that +necessity, so a server announces it on its ``/ResourceTypes`` endpoint: + +.. doctest:: + + >>> ResourceType.from_resource(PetOwnerUser).schema_extensions[0].required + True + Publish the schema ------------------ diff --git a/scim2_models/resources/resource.py b/scim2_models/resources/resource.py index d6b73079..42a81899 100644 --- a/scim2_models/resources/resource.py +++ b/scim2_models/resources/resource.py @@ -150,6 +150,19 @@ def _extension_serializer( return result or None +def _qualified_extension(parameter: Any) -> tuple[Any, tuple[Any, ...]]: + """Split an extension parameter from what qualifies it. + + ``User[Annotated[EnterpriseUser, Required.true]]`` names the extension and + says a resource of that type must carry it, which + :rfc:`RFC7643 §6 <7643#section-6>` lets a resource type declare. + """ + if get_origin(parameter) is Annotated: + extension, *qualifiers = get_args(parameter) + return extension, tuple(qualifiers) + return parameter, () + + class Resource(ScimObject, Generic[AnyExtension]): # Common attributes as defined by # https://www.rfc-editor.org/rfc/rfc7643#section-3.1 @@ -198,26 +211,34 @@ def __class_getitem__(cls, item: Any) -> type["Resource[Any]"]: if hasattr(cls, "__scim_extension_metadata__"): return cls - extensions = get_args(item) if get_origin(item) in UNION_TYPES else [item] + parameters = get_args(item) if get_origin(item) in UNION_TYPES else [item] # Skip TypeVar parameters and Any (used for generic class definitions) - valid_extensions = [ - extension - for extension in extensions - if not isinstance(extension, TypeVar) and extension is not Any + valid_parameters = [ + parameter + for parameter in parameters + if not isinstance(parameter, TypeVar) and parameter is not Any ] - if not valid_extensions: + if not valid_parameters: return cls - cache_key = (cls, tuple(valid_extensions)) + # What qualifies a parameter belongs to the key, so that a required + # extension and an optional one are two classes. + cache_key = (cls, tuple(valid_parameters)) if cache_key in _PARAMETERIZED_CLASSES: return _PARAMETERIZED_CLASSES[cache_key] - for extension in valid_extensions: + qualified_extensions = [ + _qualified_extension(parameter) for parameter in valid_parameters + ] + + for extension, _ in qualified_extensions: if not (isinstance(extension, type) and issubclass(extension, Extension)): raise TypeError(f"{extension} is not a valid Extension type") + valid_extensions = [extension for extension, _ in qualified_extensions] + class_name = ( f"{cls.__name__}[{', '.join(ext.__name__ for ext in valid_extensions)}]" ) @@ -234,10 +255,13 @@ def __class_getitem__(cls, item: Any) -> type["Resource[Any]"]: new_annotations = { extension.__name__: Annotated[ - extension | None, - WrapSerializer(_extension_serializer), + ( + extension | None, + WrapSerializer(_extension_serializer), + *qualifiers, + ) ] - for extension in valid_extensions + for extension, qualifiers in qualified_extensions } new_class = type( diff --git a/scim2_models/resources/resource_type.py b/scim2_models/resources/resource_type.py index 2c13230e..e6b59d97 100644 --- a/scim2_models/resources/resource_type.py +++ b/scim2_models/resources/resource_type.py @@ -100,7 +100,11 @@ def from_resource(cls, resource_model: type[Resource[Any]]) -> Self: schema_extensions=[ SchemaExtension( schema_=Reference[URI](extension.__schema__), - required=False, + required=bool( + resource_model.get_field_annotation( + extension.__name__, Required + ) + ), ) for extension in extensions ], diff --git a/tests/test_resource_extension.py b/tests/test_resource_extension.py index 10c68c6a..0c16dc13 100644 --- a/tests/test_resource_extension.py +++ b/tests/test_resource_extension.py @@ -1,9 +1,11 @@ import datetime import gc import weakref +from typing import Annotated import pytest from pydantic import TypeAdapter +from pydantic import ValidationError from scim2_models import URN from scim2_models import Attribute @@ -13,6 +15,7 @@ from scim2_models import InvalidPathException from scim2_models import Manager from scim2_models import Meta +from scim2_models import Required from scim2_models import Resource from scim2_models import Schema from scim2_models import User @@ -445,3 +448,68 @@ def test_a_parameterized_model_built_at_runtime_is_collected_once_it_is_dropped( gc.collect() assert reference() is None + + +def test_a_required_extension_must_be_carried_by_a_creation_request(): + """An extension a resource type declares required must be present. + + :rfc:`RFC7643 §6 <7643#section-6>` has ``schemaExtensions.required`` mean + that "a resource of this type MUST include this schema extension", which + annotating the parameter with :attr:`Required.true ` + expresses. + """ + payload = {"schemas": [User.__schema__], "userName": "bjensen"} + + with pytest.raises(ValidationError, match="Field 'EnterpriseUser' is required"): + User[Annotated[EnterpriseUser, Required.true]].model_validate( + payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST + ) + + +def test_a_required_extension_is_accepted_when_the_payload_carries_it(): + payload = { + "schemas": [User.__schema__, EnterpriseUser.__schema__], + "userName": "bjensen", + EnterpriseUser.__schema__: {"employeeNumber": "701984"}, + } + + user = User[Annotated[EnterpriseUser, Required.true]].model_validate( + payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST + ) + + assert user[EnterpriseUser].employee_number == "701984" + + +def test_an_optional_extension_may_be_left_out_of_a_creation_request(): + """An extension is optional unless the parameter says otherwise. + + :rfc:`RFC7643 §2.2 <7643#section-2.2>` makes optionality the implicit value + of any attribute, so both the bare parameter and the one annotated + :attr:`Required.false ` accept the payload. + """ + payload = {"schemas": [User.__schema__], "userName": "bjensen"} + + for model in ( + User[EnterpriseUser], + User[Annotated[EnterpriseUser, Required.false]], + ): + user = model.model_validate(payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST) + assert user[EnterpriseUser] is None + + +def test_the_necessity_of_an_extension_tells_two_parameterizations_apart(): + required = User[Annotated[EnterpriseUser, Required.true]] + + assert required is not User[EnterpriseUser] + assert required is User[Annotated[EnterpriseUser, Required.true]] + + +def test_an_annotated_extension_is_reached_the_way_a_bare_one_is(): + """The annotation qualifies the parameter, it does not rename it.""" + model = User[Annotated[EnterpriseUser, Required.true]] + user = model(user_name="bjensen") + + user[EnterpriseUser] = EnterpriseUser(employee_number="701984") + + assert user[EnterpriseUser].employee_number == "701984" + assert model.get_extension_models() == {EnterpriseUser.__schema__: EnterpriseUser} diff --git a/tests/test_resource_type.py b/tests/test_resource_type.py index ea4b2612..bf6876b3 100644 --- a/tests/test_resource_type.py +++ b/tests/test_resource_type.py @@ -1,7 +1,10 @@ +from typing import Annotated + from scim2_models import URN from scim2_models import EnterpriseUser from scim2_models import Extension from scim2_models import Reference +from scim2_models import Required from scim2_models import ResourceType from scim2_models import User @@ -90,3 +93,12 @@ class TestExtension(Extension): == "urn:ietf:params:scim:schemas:extension:Test:1.0:User" ) assert not enterprise_user_rt.schema_extensions[1].required + + +def test_from_resource_publishes_the_necessity_an_extension_is_parameterized_with(): + """:rfc:`RFC7643 §6 <7643#section-6>` carries it as ``schemaExtensions.required``.""" + resource_type = ResourceType.from_resource( + User[Annotated[EnterpriseUser, Required.true]] + ) + + assert resource_type.schema_extensions[0].required is True