From f838368d1ed9ea5bd7beffd326dcb24d5da5153f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 25 Aug 2026 22:40:46 +0200 Subject: [PATCH] feat: accept non canonical values for enum attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC7643 §2.3.1 only allows service providers to restrict string attributes to their canonicalValues, so attributes suggesting canonical values now keep arbitrary values instead of rejecting them. This covers the type attribute of Email, PhoneNumber, Im, Photo, Address and AuthenticationScheme. Canonical values are matched case-insensitively, as §2.2 makes those attributes case-insensitive, and their JSON schema advertises the canonical values as examples instead of a restrictive enum. --- doc/changelog.rst | 14 +++ scim2_models/__init__.py | 2 + scim2_models/attributes.py | 46 ++++++++++ .../resources/service_provider_config.py | 4 +- scim2_models/resources/user.py | 12 +-- tests/test_service_provider_configuration.py | 26 ++++++ tests/test_user.py | 92 +++++++++++++++++++ 7 files changed, 188 insertions(+), 8 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index c44b87c..be03c83 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -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` @@ -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 ^^^^^ diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index ceb552e..b1383ab 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -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 @@ -108,6 +109,7 @@ "EnterpriseUser", "Entitlement", "Error", + "ExtensibleStringEnum", "Extension", "External", "ExternalReference", diff --git a/scim2_models/attributes.py b/scim2_models/attributes.py index ea59321..be93579 100644 --- a/scim2_models/attributes.py +++ b/scim2_models/attributes.py @@ -1,3 +1,4 @@ +from enum import Enum from inspect import isclass from typing import Annotated from typing import Any @@ -5,6 +6,10 @@ 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 @@ -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>`.""" diff --git a/scim2_models/resources/service_provider_config.py b/scim2_models/resources/service_provider_config.py index d29352f..4eb702e 100644 --- a/scim2_models/resources/service_provider_config.py +++ b/scim2_models/resources/service_provider_config.py @@ -1,4 +1,3 @@ -from enum import Enum from typing import Annotated from typing import Any @@ -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 @@ -55,7 +55,7 @@ class ETag(ComplexAttribute): class AuthenticationScheme(ComplexAttribute): - class Type(str, Enum): + class Type(ExtensibleStringEnum): oauth = "oauth" oauth2 = "oauth2" oauthbearertoken = "oauthbearertoken" diff --git a/scim2_models/resources/user.py b/scim2_models/resources/user.py index 43b25e6..1822448 100644 --- a/scim2_models/resources/user.py +++ b/scim2_models/resources/user.py @@ -1,4 +1,3 @@ -from enum import Enum from typing import TYPE_CHECKING from typing import Annotated from typing import ClassVar @@ -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 @@ -50,7 +50,7 @@ class Name(ComplexAttribute): class Email(ComplexAttribute): - class Type(str, Enum): + class Type(ExtensibleStringEnum): work = "work" home = "home" other = "other" @@ -71,7 +71,7 @@ class Type(str, Enum): class PhoneNumber(ComplexAttribute): - class Type(str, Enum): + class Type(ExtensibleStringEnum): work = "work" home = "home" mobile = "mobile" @@ -98,7 +98,7 @@ class Type(str, Enum): class Im(ComplexAttribute): - class Type(str, Enum): + class Type(ExtensibleStringEnum): aim = "aim" gtalk = "gtalk" icq = "icq" @@ -126,7 +126,7 @@ class Type(str, Enum): class Photo(ComplexAttribute): - class Type(str, Enum): + class Type(ExtensibleStringEnum): photo = "photo" thumbnail = "thumbnail" @@ -146,7 +146,7 @@ class Type(str, Enum): class Address(ComplexAttribute): - class Type(str, Enum): + class Type(ExtensibleStringEnum): work = "work" home = "home" other = "other" diff --git a/tests/test_service_provider_configuration.py b/tests/test_service_provider_configuration.py index de3a14f..a4f3ac0 100644 --- a/tests/test_service_provider_configuration.py +++ b/tests/test_service_provider_configuration.py @@ -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" diff --git a/tests/test_user.py b/tests/test_user.py index 6c6fe23..c29a78d 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -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 @@ -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"