Skip to content

Commit 33ab2bb

Browse files
committed
fix: a PATCH operation without a path accepts a resource as its value
1 parent 39d4de2 commit 33ab2bb

3 files changed

Lines changed: 82 additions & 3 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ Fixed
1818
:attr:`Required.true <scim2_models.Required.true>` unassigned answers ``mutability`` instead
1919
of ``invalidValue``, which is the ``scimType``
2020
:rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>` names for it.
21+
- A PATCH operation carrying no ``path`` accepts a resource as its ``value``, and not only a
22+
mapping. Such a resource used to be ignored without a word: neither checked against the
23+
model, nor written to the patched resource.
2124
- A PATCH operation carrying no ``path`` has the attributes of its ``value`` checked, where
2225
they used to go through unexamined. :rfc:`RFC7644 §3.5.2.3 <7644#section-3.5.2.3>` makes
2326
them the targets of the operation, so each one answers to §3.5.2 as a named path does: a

‎scim2_models/messages/patch_op.py‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from ..annotations import Mutability
1616
from ..annotations import Required
1717
from ..attributes import ComplexAttribute
18+
from ..base import BaseModel
1819
from ..context import Context
1920
from ..exceptions import InvalidValueException
2021
from ..exceptions import MutabilityException
@@ -29,6 +30,22 @@
2930
ResourceT = TypeVar("ResourceT", bound=Resource[Any])
3031

3132

33+
def _targeted_attributes(value: Any) -> dict[str, Any]:
34+
"""Return the attributes an operation without a path writes.
35+
36+
:rfc:`RFC7644 §3.5.2.3 <7644#section-3.5.2.3>` has the ``value`` name them
37+
when the path is omitted. A client building its payload in Python passes a
38+
resource, where a server parses a mapping, and both name the same
39+
attributes. Anything else names none.
40+
"""
41+
if isinstance(value, BaseModel):
42+
# Dumped out of context on purpose: a payload dumped in the PATCH
43+
# context already leaves read-only attributes out, and an operation
44+
# naming one is to be refused rather than quietly trimmed.
45+
return value.model_dump(exclude_unset=True)
46+
return value if isinstance(value, dict) else {}
47+
48+
3249
def _resolved_field(
3350
resource_class: type[Resource[Any]], attr_name: str | None
3451
) -> str | None:
@@ -272,8 +289,7 @@ def validate_operations(self, info: ValidationInfo) -> Self:
272289
# the attributes to write. Each of them is a target of its own,
273290
# and one the model does not declare is left alone, as §3.5.2
274291
# asks servers to be tolerant of schema extensions.
275-
targeted = operation.value if isinstance(operation.value, dict) else {}
276-
for attr_name, written in targeted.items():
292+
for attr_name, written in _targeted_attributes(operation.value).items():
277293
operation._validate_mutability(resource_class, attr_name)
278294
operation._validate_required_attribute(
279295
resource_class, attr_name, written
@@ -389,10 +405,17 @@ def _apply_add_replace(
389405
"""Apply an add or replace operation."""
390406
before_state = self._capture_primary_state(resource)
391407

408+
value = operation.value
409+
if operation.path is None and isinstance(value, BaseModel):
410+
# Path("").set writes the attributes a mapping names, so a resource
411+
# given as a value is dumped to the payload it stands for, and to
412+
# the very attributes the operation was checked against.
413+
value = _targeted_attributes(value)
414+
392415
path = operation.path if operation.path is not None else Path("")
393416
modified = path.set(
394417
resource, # type: ignore[arg-type]
395-
operation.value,
418+
value,
396419
is_add=operation.op == PatchOperation.Op.add,
397420
)
398421

‎tests/test_patch_op_validation.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,7 @@ def test_a_replace_may_empty_a_required_string():
786786

787787

788788
def test_a_replace_may_unassign_an_optional_attribute():
789+
"""Only a required attribute is protected from becoming unassigned."""
789790
patch = PatchOp[User].model_validate(
790791
{
791792
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
@@ -813,6 +814,7 @@ def test_an_operation_without_path_cannot_write_a_read_only_attribute():
813814

814815

815816
def test_an_operation_without_path_cannot_write_a_read_only_complex_attribute():
817+
"""Naming a complex attribute is enough to refuse it, without descending into its sub-attributes."""
816818
with pytest.raises(ValidationError, match="mutability"):
817819
PatchOp[User].model_validate(
818820
{
@@ -826,6 +828,7 @@ def test_an_operation_without_path_cannot_write_a_read_only_complex_attribute():
826828

827829

828830
def test_an_operation_without_path_cannot_unassign_a_required_attribute():
831+
"""A null value in the ``value`` unassigns its attribute as surely as a remove does."""
829832
with pytest.raises(
830833
ValidationError, match="required attribute cannot be unassigned"
831834
):
@@ -839,6 +842,7 @@ def test_an_operation_without_path_cannot_unassign_a_required_attribute():
839842

840843

841844
def test_an_add_without_path_cannot_write_a_read_only_attribute():
845+
""":rfc:`RFC7644 §3.5.2.1 <7644#section-3.5.2.1>` gives ``add`` the same omitted path as ``replace``."""
842846
with pytest.raises(ValidationError, match="mutability"):
843847
PatchOp[User].model_validate(
844848
{
@@ -898,3 +902,52 @@ def test_unassigning_a_required_attribute_answers_mutability():
898902
scim_ctx=Context.RESOURCE_PATCH_REQUEST,
899903
)
900904
assert exc_info.value.errors()[0]["type"] == "scim_mutability"
905+
906+
907+
def test_an_operation_without_path_takes_a_model_as_value():
908+
"""A client building its payload in Python passes a resource, where a server parses a dict."""
909+
patch = PatchOp[User](
910+
operations=[
911+
PatchOperation[User](
912+
op=PatchOperation.Op.replace_, value=User(display_name="Babs")
913+
)
914+
]
915+
)
916+
user = User(id="srv-1", user_name="bjensen")
917+
assert patch.patch(user)
918+
assert user.display_name == "Babs"
919+
assert user.user_name == "bjensen"
920+
921+
922+
def test_a_model_value_answers_to_the_same_constraints_as_a_dict_one():
923+
"""The form the value takes says nothing about what the operation may write."""
924+
with pytest.raises(ValidationError, match="mutability"):
925+
PatchOp[User](
926+
operations=[
927+
PatchOperation[User](
928+
op=PatchOperation.Op.replace_,
929+
value=User(id="chosen-by-the-client"),
930+
)
931+
]
932+
)
933+
934+
935+
def test_a_model_value_carries_the_attributes_its_payload_would():
936+
"""A null attribute is not serialized, so it is neither written nor refused.
937+
938+
Unassigning through an operation without a path takes the payload form,
939+
``{"userName": null}``, which :rfc:`RFC7643 §2.5 <7643#section-2.5>` makes
940+
equivalent to an unassigned attribute.
941+
"""
942+
patch = PatchOp[User](
943+
operations=[
944+
PatchOperation[User](
945+
op=PatchOperation.Op.replace_,
946+
value=User(user_name=None, display_name="Babs"),
947+
)
948+
]
949+
)
950+
user = User(id="srv-1", user_name="bjensen")
951+
patch.patch(user)
952+
assert user.user_name == "bjensen"
953+
assert user.display_name == "Babs"

0 commit comments

Comments
 (0)