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
14 changes: 14 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Added
They look models up by schema, accept any sequence of :class:`~scim2_models.ScimObject` subclasses,
and reflect the input types in the returned type.
- :class:`~scim2_models.ScimObject` and ``AnyScimObject`` are exposed in the public API, so that downstream projects can annotate values that are either resources or messages.
- :class:`~scim2_models.ExtensibleStringEnum` is exposed in the public API, so custom models
can define string attributes that suggest canonical values without restricting them.
- :meth:`~scim2_models.BaseModel.model_validate_json` takes a ``scim_ctx`` parameter, like the
other validation and serialization methods, so JSON payloads can be validated without being
decoded first. :issue:`150`
Expand All @@ -27,6 +29,18 @@ Changed
- :meth:`~scim2_models.Resource.replace` does not mark the fields it copies from the original
resource as set anymore, so ``model_fields_set`` only holds the attributes asserted by the
client, as defined by :rfc:`7644` §3.5.1.
- Attributes suggesting :rfc:`7643` canonical values accept values outside of their canonical
set, as :rfc:`7643` §2.3.1 only allows service providers to restrict them. This covers the
``type`` attribute of :class:`~scim2_models.Email`, :class:`~scim2_models.PhoneNumber`,
:class:`~scim2_models.Im`, :class:`~scim2_models.Photo`, :class:`~scim2_models.Address` and
:class:`~scim2_models.AuthenticationScheme`.
:issue:`34`
- ``str()`` on those attributes returns the SCIM value instead of the enum representation:
``str(Email.Type.work)`` returns ``"work"`` instead of ``"Type.work"``.
- Canonical values are matched case-insensitively, as :rfc:`7643` §2.2 makes those attributes
case-insensitive: ``Email(type="WORK").type`` is ``Email.Type.work``.
- The JSON schema of those attributes advertises the canonical values as ``examples`` instead of
a restrictive ``enum``.

Fixed
^^^^^
Expand Down
2 changes: 2 additions & 0 deletions scim2_models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from .annotations import Returned
from .annotations import Uniqueness
from .attributes import ComplexAttribute
from .attributes import ExtensibleStringEnum
from .attributes import MultiValuedComplexAttribute
from .base import BaseModel
from .context import Context
Expand Down Expand Up @@ -108,6 +109,7 @@
"EnterpriseUser",
"Entitlement",
"Error",
"ExtensibleStringEnum",
"Extension",
"External",
"ExternalReference",
Expand Down
46 changes: 46 additions & 0 deletions scim2_models/attributes.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
from enum import Enum
from inspect import isclass
from typing import Annotated
from typing import Any
from typing import ClassVar
from typing import get_origin

from pydantic import Field
from pydantic import GetJsonSchemaHandler
from pydantic.json_schema import JsonSchemaValue
from pydantic_core import CoreSchema
from typing_extensions import Self

from .annotations import Mutability

Expand All @@ -13,6 +18,47 @@
from .reference import Reference


class ExtensibleStringEnum(str, Enum):
"""String enum accepting values beyond its canonical ones.

:rfc:`RFC7643 §2.3.1 <7643#section-2.3.1>` and :rfc:`§7 <7643#section-7>`
define ``canonicalValues`` as suggestions that service providers MAY restrict,
so unknown values are kept as-is instead of being rejected.
"""

def __str__(self) -> str:
return str(self.value)

@classmethod
def __get_pydantic_json_schema__(
cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
"""Advertise the canonical values as examples rather than as a closed set."""
json_schema = handler.resolve_ref_schema(handler(core_schema))
json_schema.pop("enum", None)
json_schema["examples"] = [member.value for member in cls]
return json_schema

@classmethod
def _missing_(cls, value: Any) -> Self:
"""Match canonical values regardless of their case, and keep unknown ones as-is.

Attributes bearing ``canonicalValues`` are case-insensitive unless stated
otherwise by :rfc:`RFC7643 §2.2 <7643#section-2.2>`.
"""
if not isinstance(value, str):
raise ValueError(f"{value} is not a valid string value for {cls.__name__}")

for member in cls:
if member.value.lower() == value.lower():
return member

obj = str.__new__(cls, value)
obj._name_ = value
obj._value_ = value
return obj


class ComplexAttribute(BaseModel):
"""A complex attribute as defined in :rfc:`RFC7643 §2.3.8 <7643#section-2.3.8>`."""

Expand Down
4 changes: 2 additions & 2 deletions scim2_models/resources/service_provider_config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from enum import Enum
from typing import Annotated
from typing import Any

Expand All @@ -9,6 +8,7 @@
from ..annotations import Returned
from ..annotations import Uniqueness
from ..attributes import ComplexAttribute
from ..attributes import ExtensibleStringEnum
from ..path import URN
from ..reference import External
from ..reference import Reference
Expand Down Expand Up @@ -55,7 +55,7 @@ class ETag(ComplexAttribute):


class AuthenticationScheme(ComplexAttribute):
class Type(str, Enum):
class Type(ExtensibleStringEnum):
oauth = "oauth"
oauth2 = "oauth2"
oauthbearertoken = "oauthbearertoken"
Expand Down
12 changes: 6 additions & 6 deletions scim2_models/resources/user.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from enum import Enum
from typing import TYPE_CHECKING
from typing import Annotated
from typing import ClassVar
Expand All @@ -13,6 +12,7 @@
from ..annotations import Returned
from ..annotations import Uniqueness
from ..attributes import ComplexAttribute
from ..attributes import ExtensibleStringEnum
from ..path import URN
from ..reference import External
from ..reference import Reference
Expand Down Expand Up @@ -50,7 +50,7 @@ class Name(ComplexAttribute):


class Email(ComplexAttribute):
class Type(str, Enum):
class Type(ExtensibleStringEnum):
work = "work"
home = "home"
other = "other"
Expand All @@ -71,7 +71,7 @@ class Type(str, Enum):


class PhoneNumber(ComplexAttribute):
class Type(str, Enum):
class Type(ExtensibleStringEnum):
work = "work"
home = "home"
mobile = "mobile"
Expand All @@ -98,7 +98,7 @@ class Type(str, Enum):


class Im(ComplexAttribute):
class Type(str, Enum):
class Type(ExtensibleStringEnum):
aim = "aim"
gtalk = "gtalk"
icq = "icq"
Expand Down Expand Up @@ -126,7 +126,7 @@ class Type(str, Enum):


class Photo(ComplexAttribute):
class Type(str, Enum):
class Type(ExtensibleStringEnum):
photo = "photo"
thumbnail = "thumbnail"

Expand All @@ -146,7 +146,7 @@ class Type(str, Enum):


class Address(ComplexAttribute):
class Type(str, Enum):
class Type(ExtensibleStringEnum):
work = "work"
home = "home"
other = "other"
Expand Down
26 changes: 26 additions & 0 deletions tests/test_service_provider_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,29 @@ def test_service_provider_configuration(load_sample):
assert obj.meta.version == 'W\\/"3694e05e9dff594"'

assert obj.model_dump() == payload


def test_authentication_scheme_type_is_case_insensitive():
"""Test that canonical authentication scheme types are read whatever their case is."""
scheme = AuthenticationScheme.model_validate(
{
"type": "HttpBasic",
"name": "HTTP Basic",
"description": "Authentication scheme using the HTTP Basic Standard",
}
)
assert scheme.type is AuthenticationScheme.Type.httpbasic
assert scheme.model_dump()["type"] == "httpbasic"


def test_authentication_scheme_type_accepts_unknown_schemes():
"""Test that authentication schemes beyond those defined by RFC7643 are read."""
scheme = AuthenticationScheme.model_validate(
{
"type": "oauth2bearer",
"name": "OAuth 2 Bearer Token",
"description": "Authentication scheme using an OAuth 2 bearer token",
}
)
assert str(scheme.type) == "oauth2bearer"
assert scheme.model_dump()["type"] == "oauth2bearer"
92 changes: 92 additions & 0 deletions tests/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from scim2_models import Address
from scim2_models import Email
from scim2_models import ExtensibleStringEnum
from scim2_models import Im
from scim2_models import PhoneNumber
from scim2_models import Photo
Expand Down Expand Up @@ -192,3 +193,94 @@ def test_multiple_primary_validation_skipped_without_strict_context(scim_ctx):
}
user = User.model_validate(user_data, scim_ctx=scim_ctx)
assert len(user.emails) == 2


def test_extensible_enum_canonical_values():
"""Test that canonical enum values work as expected."""

class TestEnum(ExtensibleStringEnum):
foo = "foo"
bar = "bar"

assert TestEnum.foo == "foo"
assert TestEnum.bar == "bar"
assert str(TestEnum.foo) == "foo"


def test_extensible_enum_matches_canonical_values_case_insensitively():
"""Test that canonical values are matched whatever their case is."""

class TestEnum(ExtensibleStringEnum):
foo = "foo"

assert TestEnum("FOO") is TestEnum.foo
assert TestEnum("Foo") is TestEnum.foo


def test_extensible_enum_preserves_the_case_of_arbitrary_values():
"""Test that arbitrary values keep the case they have been submitted with."""

class TestEnum(ExtensibleStringEnum):
foo = "foo"

assert str(TestEnum("BarBaz")) == "BarBaz"


def test_extensible_enum_arbitrary_values():
"""Test that arbitrary string values are accepted."""

class TestEnum(ExtensibleStringEnum):
foo = "foo"
bar = "bar"

custom = TestEnum("custom_value")
another = TestEnum("another_value")

assert str(custom) == "custom_value"
assert str(another) == "another_value"
assert custom == "custom_value"
assert another == "another_value"


def test_extensible_enum_non_string_rejected():
"""Test that non-string values are rejected."""

class TestEnum(ExtensibleStringEnum):
foo = "foo"

with pytest.raises(ValueError, match="is not a valid string value"):
TestEnum(123)

with pytest.raises(ValueError, match="is not a valid string value"):
TestEnum(None)


def test_complex_attribute_extensible_types():
"""Test that complex attribute types support RFC 7643 extensibility."""
email_canonical = Email(value="test@example.com", type=Email.Type.work)
assert str(email_canonical.type) == "work"

email_custom = Email(value="john.doe@example.com", type="company")
assert str(email_custom.type) == "company"

data = email_custom.model_dump()
assert data["type"] == "company"

restored = Email.model_validate(data)
assert str(restored.type) == "company"
assert restored.value == "john.doe@example.com"


def test_complex_attribute_types_are_case_insensitive():
"""Test that canonical attribute types are read whatever their case is."""
email = Email.model_validate({"value": "john.doe@example.com", "type": "WORK"})
assert email.type is Email.Type.work
assert email.model_dump()["type"] == "work"


def test_complex_attribute_types_json_schema_does_not_restrict_values():
"""Test that canonical values are advertised as examples in the JSON schema."""
schema = Email.model_json_schema()["$defs"]["Type"]
assert "enum" not in schema
assert schema["examples"] == ["work", "home", "other"]
assert schema["type"] == "string"
Loading