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"