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
33 changes: 33 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ Added

Changed
^^^^^^^
- :meth:`Resource.from_schema <scim2_models.Resource.from_schema>` and
:meth:`Extension.from_schema <scim2_models.Extension.from_schema>` refuse a schema declaring
two attributes whose names only differ by case, and report both.
:rfc:`RFC7643 §2.1 <7643#section-2.1>` makes them one attribute, so such a schema describes
it twice. :issue:`166`
- Attribute names are matched case-insensitively, and nothing else. The ``nameChar`` rule of
:rfc:`RFC7643 §2.1 <7643#section-2.1>` makes ``$``, ``-`` and ``_`` part of a name, so
``{"user-name": "x"}``, which 0.7 read as ``userName``, is now an unknown attribute that
:attr:`~scim2_models.ScimPolicy.unknown` governs. Paths, filters and ``sortBy`` resolve the
same way. A model whose fields answer to one attribute name raises a :class:`TypeError` where
it is defined. :issue:`166`
- The bulk models take the resource type their operations carry, as in ``BulkRequest[User]`` or
``BulkRequest[User | Group]``, and raise a :class:`TypeError` when used bare. A payload the type
parameter does not cover is now refused, and a bulk response no longer dumps ``path``.
Expand Down Expand Up @@ -124,6 +135,28 @@ Deprecated

Fixed
^^^^^
- A PATCH operation carrying no ``path`` that unassigns an extension declared
:attr:`Required.true <scim2_models.Required.true>` is refused. The extension was named by its
URN, which the constraint checks did not resolve, so they found no constraint to answer for.
:issue:`166`
- A filter or a path accepts a ``$`` anywhere in an attribute name, as ``nameChar`` allows. Only
a leading one went through. :issue:`166`
- A schema declaring several attributes that yield one Python name builds a field for each of
them: the attribute already spelled as that name keeps it, and the others are held under their
SCIM name. ``employee_id`` and ``employeeId`` used to share one field, so a dump reported one
value twice and lost the other. An attribute is read under the name SCIM gives it, as in
``resource["employeeId"]``, so a field name that is no Python identifier costs nothing.
:issue:`166`
- A pydantic error spells the attribute as SCIM does, ``userName`` and ``$ref`` where it used to
report ``username`` and ``ref``, and so does the JSON schema a model publishes, which FastAPI
reads to document a request body. :issue:`166`
- A field declaring its own ``alias`` is read under it, and an unknown attribute is refused under
the spelling the peer used. ``Field(alias="string_field")`` used to answer ``extra_forbidden``
quoting a spelling nobody had sent. :issue:`166`
- An extension is read under its class name as well as under its URN, so
``User[EnterpriseUser](EnterpriseUser=extension)`` is accepted and a resource carrying an
extension survives a dump without aliases read back. Both used to answer ``extra_forbidden``.
:issue:`166`
- A bulk model indexed with something other than a resource type names itself in the error. The
rules of the :class:`~scim2_models.PatchOp` its operations carry used to answer for it, so
``BulkRequest[str]`` told the caller to write ``PatchOp[User]``.
Expand Down
41 changes: 41 additions & 0 deletions doc/how-to/define-custom-models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,47 @@ as optional because SCIM can omit an attribute in a valid request or response.
>>> pet.model_dump()["details"]
{'color': 'ginger', 'weightKg': 4.2}

Name an attribute
-----------------

A field named in Python takes the camel-case spelling of its name as its SCIM attribute name:
``weight_kg`` becomes ``weightKg``. Reading a payload goes the other way, and
:rfc:`RFC7643 §2.1 <7643#section-2.1>` makes attribute names case-insensitive, so ``weightKg``,
``weightkg`` and ``WEIGHTKG`` all reach ``weight_kg``. Nothing else is folded: the ABNF of that
section makes ``$``, ``-`` and ``_`` part of a name, so ``weight-kg`` is another attribute
altogether, refused as an unknown one. See :doc:`tolerate-a-nonconformant-peer` to accept what a peer
spells its own way.

When the attribute name is not what camel-casing a Python name yields, declare it with a
``serialization_alias``:

.. doctest::

>>> from pydantic import Field
>>> class Pet(Resource):
... __schema__ = URN("urn:example:schemas:Pet")
... vet_ref: str | None = Field(None, serialization_alias="$vetRef")
...
>>> Pet.model_validate({"$vetRef": "https://example.com/Vets/1"}).vet_ref
'https://example.com/Vets/1'

An alias applies to reading as well as to writing, and it wins over the Python name of any other
field. A field whose alias is the Python name of its neighbour therefore takes the
keyword that spells it, in a payload and in the constructor alike:

.. doctest::

>>> class Pet(Resource):
... __schema__ = URN("urn:example:schemas:Pet")
... pet_name: str | None = None
... legacy: str | None = Field(None, serialization_alias="pet_name")
...
>>> Pet(pet_name="Mochi").legacy
'Mochi'

Two fields cannot answer to one attribute name. Such a model raises a :class:`TypeError` where it
is defined, no payload key being able to reach both.

Apply SCIM attribute metadata
-----------------------------

Expand Down
9 changes: 4 additions & 5 deletions scim2_models/attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,7 @@ def _get_attribute_urn(self, field_name: str) -> str:

See RFC7644 §3.10.
"""
alias = (
self.__class__.model_fields[field_name].serialization_alias or field_name
)
return f"{self._attribute_urn}.{alias}"
return f"{self._attribute_urn}.{self._scim_name(field_name)}"


class MultiValuedComplexAttribute(ComplexAttribute):
Expand All @@ -95,7 +92,9 @@ class MultiValuedComplexAttribute(ComplexAttribute):
value: Any | None = None
"""The value of an entitlement."""

ref: Reference[Any] | None = Field(None, serialization_alias="$ref")
ref: Reference[Any] | None = Field(
None, serialization_alias="$ref", validation_alias="$ref"
)
"""The reference URI of a target resource, if the attribute is a
reference."""

Expand Down
171 changes: 135 additions & 36 deletions scim2_models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from typing import get_args
from typing import get_origin

from pydantic import AliasChoices
from pydantic import AliasGenerator
from pydantic import Base64Bytes
from pydantic import BaseModel as PydanticBaseModel
Expand All @@ -23,6 +24,7 @@
from pydantic import ValidatorFunctionWrapHandler
from pydantic import model_serializer
from pydantic import model_validator
from pydantic.fields import FieldInfo
from pydantic_core import InitErrorDetails
from pydantic_core import PydanticCustomError
from typing_extensions import Self
Expand Down Expand Up @@ -118,9 +120,10 @@ class _SCIMClassInfo(NamedTuple):
"""SCIM metadata for BaseModel."""

alias_to_field: Mapping[str, str] = MappingProxyType({})
"""Alias -> Python field name.
"""Serialization alias -> Python field name.

Holds both validation and serialization aliases.
Keyed by the spelling a dump carries, so a serializer can walk back from a
key it produced to the field that holds it.
"""

attribute_urns: Mapping[str, str] = MappingProxyType({})
Expand All @@ -132,12 +135,92 @@ class _SCIMClassInfo(NamedTuple):
extensions: frozenset[str] = frozenset()
"""Field names whose root type is a ``Extension`` subclass."""

known_keys: frozenset[str] = frozenset()
"""Every payload key the class accepts, normalized.
validation_names: Mapping[str, str] = MappingProxyType({})
"""""Python field name -> the name pydantic reads that field under.

Field names and aliases alike: an extension is named by its URN in a
payload and by its class name as a field, and both name the same thing.
An attribute reaches pydantic under its SCIM spelling, which a validation
error and a published JSON schema then carry.
""" ""

field_by_name: Mapping[str, str] = MappingProxyType({})
"""Lowercased attribute name -> Python field name.

Every spelling a payload may use for a field: the name it is serialized
under, the aliases it declares for itself, and its Python name. RFC7643
§2.1 makes attribute names case-insensitive and nothing else — its
``nameChar`` rule makes ``$``, ``-`` and ``_`` part of a name — so the keys
are lowercased and keep their punctuation.
"""


_DECLARED_ALIAS_PRIORITY = 2
"""The ``alias_priority`` pydantic gives an alias the field itself declares, an
alias generator filling the slots left empty with a priority of 1."""


def _declared_validation_names(field: FieldInfo) -> list[str]:
"""Return the names a field declares for itself.

Only the field itself declares an attribute name: what the alias generator
derived from a Python name is how pydantic reads the field, not a spelling
a peer may use. An AliasChoices holds several spellings of one attribute,
each of them usable. An AliasPath points at a place inside the payload
rather than at an attribute, so it indexes nothing: the key it starts from
is no attribute name, and reaches pydantic as the peer spelled it.
"""
if field.alias_priority != _DECLARED_ALIAS_PRIORITY:
return []

alias = field.validation_alias
if isinstance(alias, str):
return [alias]
if isinstance(alias, AliasChoices):
return [choice for choice in alias.choices if isinstance(choice, str)]
return []


def _validation_name(field: FieldInfo, field_name: str) -> str:
"""Return the name pydantic reads a field under.

The alias generator gives every field its SCIM attribute name, which an
error and a published JSON schema then carry. A field declaring
several spellings of its own names none of them in particular, and is read
under its Python name.
"""
alias = field.validation_alias
return alias if isinstance(alias, str) else field_name


def _claim_attribute_name(
index: dict[str, str], name: str, field_name: str, owner: type
) -> None:
"""Record that a field answers to an attribute name.

Two fields answering to one name leave a payload key reaching both, which
the class cannot be built with, so such a class is refused where it is
written.
"""
key = name.lower()
claimed = index.get(key)
if claimed is not None and claimed != field_name:
raise TypeError(
f"{owner.__name__} has two fields answering to the SCIM attribute "
f"name {name!r}: {claimed!r} and {field_name!r}. Attribute names are "
f"case-insensitive (RFC7643 §2.1), so one payload key would reach both."
)
index[key] = field_name


def _claim_python_name(index: dict[str, str], field_name: str) -> None:
"""Record that a field answers to its own Python name.

That name is a convenience rather than an attribute name, so two fields
whose names only differ by case take it from each other instead of making
the class impossible to build: the key then designates neither, leaving the
SCIM name of each of them the only way to reach it.
"""
key = field_name.lower()
index[key] = "" if key in index and index[key] != field_name else field_name


def _holds_reference(model: type["BaseModel"], field_name: str) -> bool:
Expand Down Expand Up @@ -172,11 +255,12 @@ class BaseModel(PydanticBaseModel):

model_config = ConfigDict(
alias_generator=AliasGenerator(
validation_alias=_normalize_attribute_name,
validation_alias=_to_camel,
serialization_alias=_to_camel,
),
validate_assignment=True,
populate_by_name=True,
validate_by_name=True,
validate_by_alias=True,
use_attribute_docstrings=True,
extra="forbid",
)
Expand Down Expand Up @@ -329,6 +413,11 @@ def get_field_multiplicity(cls, attribute_name: str) -> bool:
origin = get_origin(attribute_type)
return isinstance(origin, type) and issubclass(origin, list)

@classmethod
def _scim_name(cls, field_name: str) -> str:
"""Return the name a field is serialized under, ``$ref`` included."""
return cls.model_fields[field_name].serialization_alias or _to_camel(field_name)

@classmethod
def __pydantic_on_complete__(cls) -> None:
"""Build the per-class SCIM metadata table on ``cls.__scim_info__``.
Expand All @@ -342,6 +431,9 @@ def __pydantic_on_complete__(cls) -> None:
attribute_urns: dict[str, str] = {}
complex_fields: set[str] = set()
extensions: set[str] = set()
scim_names: dict[str, str] = {}
python_names: dict[str, str] = {}
validation_names: dict[str, str] = {}

main_schema = getattr(cls, "__schema__", None)
extension_cls: type | None = None
Expand All @@ -352,10 +444,16 @@ def __pydantic_on_complete__(cls) -> None:

for field_name, field in cls.model_fields.items():
# Alias -> field name mapping
serialization_alias = field.serialization_alias or field_name
serialization_alias = cls._scim_name(field_name)
alias_to_field[serialization_alias] = field_name
if isinstance(field.validation_alias, str):
alias_to_field[field.validation_alias] = field_name

# The names this field answers to, the SCIM ones winning over the
# Python one, which is only the spelling pydantic offers.
_claim_attribute_name(scim_names, serialization_alias, field_name, cls)
for declared in _declared_validation_names(field):
_claim_attribute_name(scim_names, declared, field_name, cls)
_claim_python_name(python_names, field_name)
validation_names[field_name] = _validation_name(field, field_name)

root_type = cls.get_field_root_type(field_name)

Expand Down Expand Up @@ -385,41 +483,42 @@ def __pydantic_on_complete__(cls) -> None:
attribute_urns=attribute_urns,
complex_fields=frozenset(complex_fields),
extensions=frozenset(extensions),
known_keys=frozenset(
_normalize_attribute_name(key)
for key in (*cls.model_fields, *alias_to_field)
),
field_by_name={
**{key: name for key, name in python_names.items() if name},
**scim_names,
},
validation_names=validation_names,
)

@model_validator(mode="wrap")
@classmethod
def _normalize_attribute_names(
def _resolve_attribute_names(
cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo
) -> Self:
"""Normalize payload attribute names, and set aside the ones no field declares.
"""Rewrite each payload key to the field holding it, and set aside the rest.

RFC7643 §2.1 indicate that attribute names should be case-insensitive.
Any attribute name is transformed in lowercase so any case is handled
the same way.
RFC7643 §2.1 makes attribute names case-insensitive, so a key is looked
up folded. What it resolves to is the Python name of the field, which
is the one spelling pydantic accepts for every field.

Unless the policy forbids them, unknown keys are taken out of the
payload with the spelling the peer used. Pydantic never sees them, so
the ``extra="forbid"`` of the class has nothing to refuse.
A key no field answers to is taken out of the payload with the spelling
the peer used, unless the policy forbids unknown attributes: it is then
left in place, so that the error pydantic raises quotes what was sent.
"""
unknown: dict[str, Any] = {}
if isinstance(value, dict):
if _policy(info).unknown == ScimPolicy.Unknown.forbid:
value = {_normalize_attribute_name(k): v for k, v in value.items()}
else:
known = cls.__scim_info__.known_keys
normalized = {}
for key, item in value.items():
name = _normalize_attribute_name(key)
if name in known:
normalized[name] = item
else:
unknown[key] = item
value = normalized
scim_info = cls.__scim_info__
tolerated = _policy(info).unknown != ScimPolicy.Unknown.forbid
resolved: dict[Any, Any] = {}
for key, item in value.items():
field_name = scim_info.field_by_name.get(_normalize_attribute_name(key))
if field_name is not None:
resolved[scim_info.validation_names[field_name]] = item
elif tolerated:
unknown[key] = item
else:
resolved[key] = item
value = resolved

obj = cast(Self, handler(value))
if unknown:
Expand Down Expand Up @@ -667,7 +766,7 @@ def _set_complex_attribute_urns(self) -> None:

``_attribute_urn`` is later read by _get_attribute_urn.
"""
for field_name in self.__class__.__scim_info__.complex_fields:
for field_name in self.__scim_info__.complex_fields:
attr_value = getattr(self, field_name)
if not attr_value:
continue
Expand Down
Loading
Loading