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
6 changes: 6 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ Added
against the first type declaring it, so ``sortBy`` answers on a root query too.
- :meth:`Path.resolve <scim2_models.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 <scim2_models.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
Expand Down
28 changes: 28 additions & 0 deletions doc/how-to/define-custom-models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <scim2_models.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 <scim2_models.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
------------------

Expand Down
46 changes: 35 additions & 11 deletions scim2_models/resources/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)}]"
)
Expand All @@ -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(
Expand Down
6 changes: 5 additions & 1 deletion scim2_models/resources/resource_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
],
Expand Down
68 changes: 68 additions & 0 deletions tests/test_resource_extension.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 <scim2_models.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 <scim2_models.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}
12 changes: 12 additions & 0 deletions tests/test_resource_type.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Loading