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
16 changes: 16 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ Changed
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``.
- The ``schemas`` attribute of SCIM payloads is built from the model definition on
serialization, as it describes the serialized document rather than the object. It holds what
a peer asserted, and is empty when a payload omitted it, so the omission stays visible in
``model_fields_set``. Objects built by the caller are still filled, as the model they are
built from asserts their type.
- Resources omitting their ``schemas`` attribute are read instead of being rejected, which
covers the partial responses of :rfc:`7644` §3.4.3. Their type comes from the
:class:`~scim2_models.ListResponse` parameter, so a response holding several resource types
still cannot decide the type of an unlabelled resource. :issue:`20`
- A ``schemas`` attribute that does not contain the model base schema is rejected whatever the
validation context, as an object cannot contradict the model it is an instance of. It used to
be accepted without a SCIM context.
- The ``schemas`` attribute is not subject to attribute filtering anymore, as :rfc:`7643` §3
requires it in every representation. It lost its :attr:`~scim2_models.Returned.always`
annotation, which :rfc:`7643` does not define for it, and which the filtering exemption
replaces.

Fixed
^^^^^
Expand Down
5 changes: 4 additions & 1 deletion doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,10 @@ modified.
>>> from scim2_models import User, Context
>>> existing = User(user_name="bjensen")
>>> replacement = User.model_validate(
... {"userName": "bjensen"},
... {
... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
... "userName": "bjensen",
... },
... scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
... )
>>> replacement.replace(existing)
Expand Down
4 changes: 4 additions & 0 deletions scim2_models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,10 @@ def _scim_response_serializer(
) -> None:
"""Serialize the fields according to returnability indications passed in the serialization context."""
for alias in set(serialized):
# RFC7643 §3 requires 'schemas' in every representation
if alias == "schemas":
continue

field_name = self.__scim_info__.alias_to_field.get(alias, alias)
returnability = self.get_field_annotation(field_name, Returned)
attribute_urn = self.get_attribute_urn(field_name)
Expand Down
9 changes: 6 additions & 3 deletions scim2_models/messages/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,12 @@ def get_schema_from_payload(payload: Any) -> str | None:
if not payload:
return None

payload_schemas = (
payload.get("schemas", []) if isinstance(payload, dict) else payload.schemas
)
if isinstance(payload, dict):
payload_schemas = payload.get("schemas", [])
else:
# An instance asserts its type by its class.
schema = getattr(type(payload), "__schema__", None)
payload_schemas = ([str(schema)] if schema else []) + list(payload.schemas)

common_schemas = [
schema for schema in payload_schemas if schema in resource_types_schemas
Expand Down
14 changes: 3 additions & 11 deletions scim2_models/resources/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from pydantic import ValidationInfo
from pydantic import ValidatorFunctionWrapHandler
from pydantic import WrapSerializer
from pydantic import field_serializer
from pydantic import model_validator
from pydantic_core import PydanticCustomError
from typing_extensions import Self
Expand Down Expand Up @@ -360,16 +359,9 @@ def get_by_payload(
)
return get_model_by_payload(resource_types, payload, **kwargs)

@field_serializer("schemas")
def set_extension_schemas(
self, schemas: Annotated[list[str], Required.true]
) -> list[str]:
"""Add model extension ids to the 'schemas' attribute."""
extension_schemas = self.get_extension_models().keys()
schemas = self.schemas + [
schema for schema in extension_schemas if schema not in self.schemas
]
return schemas
def _model_schemas(self) -> list[str]:
"""List the base schema and the schemas of the declared extensions."""
return super()._model_schemas() + list(self.get_extension_models())

@model_validator(mode="wrap")
@classmethod
Expand Down
67 changes: 51 additions & 16 deletions scim2_models/scim_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
from typing import ClassVar
from typing import TypeVar

from pydantic import Field
from pydantic import ValidationInfo
from pydantic import ValidatorFunctionWrapHandler
from pydantic import field_serializer
from pydantic import model_validator
from pydantic_core import PydanticCustomError
from typing_extensions import Self

from .annotations import Required
from .annotations import Returned
from .base import BaseModel
from .context import Context
from .path import URN
Expand All @@ -21,36 +22,70 @@
class ScimObject(BaseModel):
__schema__: ClassVar[URN | None] = None

schemas: Annotated[list[str], Required.true, Returned.always]
schemas: Annotated[list[str], Required.true] = Field(default_factory=list)
"""The "schemas" attribute is a REQUIRED attribute and is an array of
Strings containing URIs that are used to indicate the namespaces of the
SCIM schemas that define the attributes present in the current JSON
structure."""
structure.

It only holds what a peer asserted, and is empty when a payload omitted
it: SCIM dumps build it from the model definition.
"""

def _model_schemas(self) -> list[str]:
"""List the schemas asserted by the model definition."""
schema = getattr(self.__class__, "__schema__", None)
return [str(schema)] if schema else []

@field_serializer("schemas")
def _serialize_schemas(self, schemas: list[str]) -> list[str]:
"""Build the 'schemas' attribute from the model definition.

Unknown schemas a peer sent are kept, as :rfc:`RFC7643 §3
<7643#section-3>` does not restrict the array to known schemas.
"""
serialized = self._model_schemas()
for schema in schemas:
if schema not in serialized:
serialized.append(schema)
return serialized

@model_validator(mode="before")
@classmethod
def _populate_schemas_default(cls, data: Any) -> Any:
"""Auto-generate schemas from __schema__ if not provided."""
if isinstance(data, dict) and "schemas" not in data:
schema = getattr(cls, "__schema__", None)
if schema:
data = {**data, "schemas": [schema]}
return data
def _populate_schemas_default(cls, data: Any, info: ValidationInfo) -> Any:
"""Fill the schemas of objects built by the caller.

The model they are built from asserts their type. Payloads validated
in a SCIM context come from a peer, so what they omitted stays
omitted.
"""
if not isinstance(data, dict) or "schemas" in data:
return data

schema = getattr(cls, "__schema__", None)
if not schema:
return data

scim_ctx = info.context.get("scim") if info.context else None
if scim_ctx and scim_ctx != Context.DEFAULT:
return data

return {**data, "schemas": [schema]}

@model_validator(mode="wrap")
@classmethod
def _validate_schemas_attribute(
cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo
) -> Self:
"""Validate that the base schema is present in schemas attribute."""
obj: Self = handler(value)
"""Validate that the schemas a payload asserts match the model.

scim_ctx = info.context.get("scim") if info.context else None
if scim_ctx is None or scim_ctx == Context.DEFAULT:
return obj
An object cannot contradict the model it is an instance of, whatever
the validation context. An omitted attribute asserts nothing.
"""
obj: Self = handler(value)

schema = getattr(cls, "__schema__", None)
if schema and schema not in obj.schemas:
if schema and obj.schemas and schema not in obj.schemas:
raise PydanticCustomError(
"schema_error",
"schemas must contain the base schema '{schema}'",
Expand Down
58 changes: 56 additions & 2 deletions tests/test_list_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,51 @@ def test_missing_resource_schema(load_sample):
ListResponse[User].model_validate(payload, strict=True)


def test_resources_without_schemas_are_read(load_sample):
"""Resources omitting their 'schemas' attribute are read against the ListResponse parameter.

:rfc:`RFC7644 §3.4.3 <7644#section-3.4.3>` displays partial responses
where resources bear no 'schemas' attribute. A single-typed ListResponse
knows their type, so they are read as-is and the attribute is rebuilt on
serialization.
"""
payload = load_sample("rfc7644-3.4.3-list_response-post_query.json")

response = ListResponse[User].model_validate(
payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE
)

assert [resource.id for resource in response.resources] == [
"2819c223-7f76-413861904646",
"c8596b90-7539-4f20968d1908",
]
assert all(resource.schemas == [] for resource in response.resources)
assert all(
resource["schemas"] == ["urn:ietf:params:scim:schemas:core:2.0:User"]
for resource in response.model_dump()["Resources"]
)


def test_resources_without_schemas_need_a_single_type(load_sample):
"""A ListResponse holding several types cannot guess the type of an unlabelled resource.

The :rfc:`RFC7644 §3.4.3 <7644#section-3.4.3>` example is undecidable:
its second resource only bears 'id' and 'displayName', which both
:class:`~scim2_models.User` and :class:`~scim2_models.Group` define.
"""
payload = load_sample("rfc7644-3.4.3-list_response-post_query.json")

with pytest.raises(ValidationError) as exc_info:
ListResponse[User | Group].model_validate(
payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE
)

assert [error["type"] for error in exc_info.value.errors()] == [
"union_tag_not_found",
"union_tag_not_found",
]


def test_zero_results():
""":rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>` indicates that ListResponse.Resources is required when ListResponse.totalResults is non- zero.

Expand All @@ -172,6 +217,7 @@ def test_zero_results():
zero.
"""
payload = {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 1,
"Resources": [
{
Expand All @@ -183,10 +229,17 @@ def test_zero_results():
}
ListResponse[User].model_validate(payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE)

payload = {"totalResults": 1, "Resources": []}
payload = {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 1,
"Resources": [],
}
ListResponse[User].model_validate(payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE)

payload = {"totalResults": 1}
payload = {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 1,
}
with pytest.raises(ValidationError):
ListResponse[User].model_validate(
payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE
Expand Down Expand Up @@ -328,6 +381,7 @@ def test_model_dump_without_scim_context():
def test_total_results_required():
"""ListResponse.total_results is required."""
payload = {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"Resources": [
{
"schemas": [
Expand Down
Loading
Loading