Skip to content

Commit 7755737

Browse files
authored
Merge pull request #87 from python-scim/86-replacement
compare immutable attributes in replacement requests
2 parents 660d701 + 37902d3 commit 7755737

9 files changed

+198
-24
lines changed

doc/changelog.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
11
Changelog
22
=========
33

4+
[0.3.0] - Unreleased
5+
--------------------
6+
7+
Changed
8+
^^^^^^^
9+
- Add a :paramref:`~scim2_models.BaseModel.model_validate.original`
10+
parameter to :meth:`~scim2_models.BaseModel.model_validate`
11+
mandatory for :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`.
12+
This *original* value is used to look if :attr:`~scim2_models.Mutability.immutable`
13+
parameters have mutated.
14+
:issue:`86`
15+
416
[0.2.12] - 2024-12-09
517
---------------------
18+
619
Added
720
^^^^^
821
- Implement :meth:`Attribute.get_attribute <scim2_models.Attribute.get_attribute>`.
922

1023
[0.2.11] - 2024-12-08
1124
---------------------
25+
1226
Added
1327
^^^^^
1428
- Implement :meth:`Schema.get_attribute <scim2_models.Schema.get_attribute>`.

doc/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"sphinx.ext.viewcode",
1818
"sphinxcontrib.autodoc_pydantic",
1919
"sphinx_issues",
20+
"sphinx_paramlinks",
2021
"sphinx_togglebutton",
2122
"myst_parser",
2223
]

doc/tutorial.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ fields with unexpected values will raise :class:`~pydantic.ValidationError`:
102102
... except pydantic.ValidationError:
103103
... obj = Error(...)
104104
105+
.. note::
106+
107+
With the :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST` context,
108+
:meth:`~scim2_models.BaseModel.model_validate` takes an additional
109+
:paramref:`~scim2_models.BaseModel.model_validate.original` argument that is used to compare
110+
:attr:`~scim2_models.Mutability.immutable` attributes, and raise an exception when they have mutated.
111+
105112
Attributes inclusions and exclusions
106113
====================================
107114

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ doc = [
4848
"autodoc-pydantic>=2.2.0",
4949
"myst-parser>=3.0.1",
5050
"shibuya>=2024.5.15",
51+
"sphinx-paramlinks>=0.6.0",
5152
"sphinx>=7.3.7",
5253
"sphinx-issues >= 5.0.0",
5354
"sphinx-togglebutton>=0.3.2",

scim2_models/base.py

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,9 @@ class Context(Enum):
233233
Should be used for clients building a payload for a resource replacement request,
234234
and servers validating resource replacement request payloads.
235235
236-
- When used for serialization, it will not dump attributes annotated with :attr:`~scim2_models.Mutability.read_only` and :attr:`~scim2_models.Mutability.immutable`.
236+
- When used for serialization, it will not dump attributes annotated with :attr:`~scim2_models.Mutability.read_only`.
237237
- When used for validation, it will ignore attributes annotated with :attr:`scim2_models.Mutability.read_only` and raise a :class:`~pydantic.ValidationError`:
238-
- when finding attributes annotated with :attr:`~scim2_models.Mutability.immutable`,
238+
- when finding attributes annotated with :attr:`~scim2_models.Mutability.immutable` different than :paramref:`~scim2_models.BaseModel.model_validate.original`:
239239
- when attributes annotated with :attr:`Required.true <scim2_models.Required.true>` are missing on null.
240240
"""
241241

@@ -492,12 +492,6 @@ def check_request_attributes_mutability(
492492
):
493493
raise exc
494494

495-
if (
496-
context == Context.RESOURCE_REPLACEMENT_REQUEST
497-
and mutability == Mutability.immutable
498-
):
499-
raise exc
500-
501495
if (
502496
context
503497
in (Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_REPLACEMENT_REQUEST)
@@ -604,8 +598,55 @@ def check_response_attributes_necessity(
604598

605599
return value
606600

601+
@model_validator(mode="wrap")
602+
@classmethod
603+
def check_replacement_request_mutability(
604+
cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo
605+
) -> Self:
606+
"""Check if 'immutable' attributes have been mutated in replacement requests."""
607+
from scim2_models.rfc7643.resource import Resource
608+
609+
value = handler(value)
610+
611+
context = info.context.get("scim") if info.context else None
612+
original = info.context.get("original") if info.context else None
613+
if (
614+
context == Context.RESOURCE_REPLACEMENT_REQUEST
615+
and issubclass(cls, Resource)
616+
and original is not None
617+
):
618+
cls.check_mutability_issues(original, value)
619+
return value
620+
621+
@classmethod
622+
def check_mutability_issues(cls, original: "BaseModel", replacement: "BaseModel"):
623+
"""Compare two instances, and check for differences of values on the fields marked as immutable."""
624+
model = replacement.__class__
625+
for field_name in model.model_fields:
626+
mutability = model.get_field_annotation(field_name, Mutability)
627+
if mutability == Mutability.immutable and getattr(
628+
original, field_name
629+
) != getattr(replacement, field_name):
630+
raise PydanticCustomError(
631+
"mutability_error",
632+
"Field '{field_name}' is immutable but the request value is different than the original value.",
633+
{"field_name": field_name},
634+
)
635+
636+
attr_type = model.get_field_root_type(field_name)
637+
if is_complex_attribute(attr_type) and not model.get_field_multiplicity(
638+
field_name
639+
):
640+
original_val = getattr(original, field_name)
641+
replacement_value = getattr(replacement, field_name)
642+
if original_val is not None and replacement_value is not None:
643+
cls.check_mutability_issues(original_val, replacement_value)
644+
607645
def mark_with_schema(self):
608-
"""Navigate through attributes and sub-attributes of type ComplexAttribute, and mark them with a '_schema' attribute. '_schema' will later be used by 'get_attribute_urn'."""
646+
"""Navigate through attributes and sub-attributes of type ComplexAttribute, and mark them with a '_schema' attribute.
647+
648+
'_schema' will later be used by 'get_attribute_urn'.
649+
"""
609650
from scim2_models.rfc7643.resource import Resource
610651

611652
for field_name in self.model_fields:
@@ -653,7 +694,8 @@ def scim_request_serializer(self, value: Any, info: SerializationInfo) -> Any:
653694
scim_ctx = info.context.get("scim") if info.context else None
654695

655696
if (
656-
scim_ctx == Context.RESOURCE_CREATION_REQUEST
697+
scim_ctx
698+
in (Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_REPLACEMENT_REQUEST)
657699
and mutability == Mutability.read_only
658700
):
659701
return None
@@ -668,12 +710,6 @@ def scim_request_serializer(self, value: Any, info: SerializationInfo) -> Any:
668710
):
669711
return None
670712

671-
if scim_ctx == Context.RESOURCE_REPLACEMENT_REQUEST and mutability in (
672-
Mutability.immutable,
673-
Mutability.read_only,
674-
):
675-
return None
676-
677713
return value
678714

679715
def scim_response_serializer(self, value: Any, info: SerializationInfo) -> Any:
@@ -719,10 +755,28 @@ def model_serializer_exclude_none(
719755

720756
@classmethod
721757
def model_validate(
722-
cls, *args, scim_ctx: Optional[Context] = Context.DEFAULT, **kwargs
758+
cls,
759+
*args,
760+
scim_ctx: Optional[Context] = Context.DEFAULT,
761+
original: Optional["BaseModel"] = None,
762+
**kwargs,
723763
) -> Self:
724-
"""Validate SCIM payloads and generate model representation by using Pydantic :code:`BaseModel.model_validate`."""
725-
kwargs.setdefault("context", {}).setdefault("scim", scim_ctx)
764+
"""Validate SCIM payloads and generate model representation by using Pydantic :code:`BaseModel.model_validate`.
765+
766+
:param scim_ctx: The SCIM :class:`~scim2_models.Context` in which the validation happens.
767+
:param original: If this parameter is set during :attr:`~Context.RESOURCE_REPLACEMENT_REQUEST`,
768+
:attr:`~scim2_models.Mutability.immutable` parameters will be compared against the *original* model value.
769+
An exception is raised if values are different.
770+
"""
771+
context = kwargs.setdefault("context", {})
772+
context.setdefault("scim", scim_ctx)
773+
context.setdefault("original", original)
774+
775+
if scim_ctx == Context.RESOURCE_REPLACEMENT_REQUEST and original is None:
776+
raise ValueError(
777+
"Resource queries replacement validation must compare to an original resource"
778+
)
779+
726780
return super().model_validate(*args, **kwargs)
727781

728782
def _prepare_model_dump(

tests/test_model_serialization.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ def test_dump_replacement_request(mut_resource):
151151
"schemas": ["org:example:MutResource"],
152152
"readWrite": "x",
153153
"writeOnly": "x",
154+
"immutable": "x",
154155
}
155156

156157

tests/test_model_validation.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55
from pydantic import ValidationError
66

7+
from scim2_models.base import ComplexAttribute
78
from scim2_models.base import Context
89
from scim2_models.base import Mutability
910
from scim2_models.base import Required
@@ -144,31 +145,113 @@ def test_validate_replacement_request_mutability():
144145
"""Test query validation for resource model replacement requests.
145146
146147
Attributes marked as:
147-
- Mutability.immutable raise a ValidationError
148+
- Mutability.immutable raise a ValidationError if different than the 'original' item.
148149
- Mutability.read_only are ignored
149150
"""
151+
with pytest.raises(
152+
ValueError,
153+
match="Resource queries replacement validation must compare to an original resource",
154+
):
155+
MutResource.model_validate(
156+
{
157+
"readOnly": "x",
158+
"readWrite": "x",
159+
"writeOnly": "x",
160+
},
161+
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
162+
)
163+
164+
original = MutResource(read_only="y", read_write="y", write_only="y", immutable="y")
150165
assert MutResource.model_validate(
151166
{
152167
"readOnly": "x",
153168
"readWrite": "x",
154169
"writeOnly": "x",
170+
"immutable": "y",
155171
},
156172
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
173+
original=original,
157174
) == MutResource(
158175
schemas=["org:example:MutResource"],
159176
readWrite="x",
160177
writeOnly="x",
178+
immutable="y",
179+
)
180+
181+
MutResource.model_validate(
182+
{
183+
"immutable": "y",
184+
},
185+
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
186+
original=original,
161187
)
162188

163189
with pytest.raises(
164190
ValidationError,
165-
match="Field 'immutable' has mutability 'immutable' but this in not valid in resource replacement request context",
191+
match="Field 'immutable' is immutable but the request value is different than the original value.",
166192
):
167193
MutResource.model_validate(
168194
{
169195
"immutable": "x",
170196
},
171197
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
198+
original=original,
199+
)
200+
201+
202+
def test_validate_replacement_request_mutability_sub_attributes():
203+
"""Test query validation for resource model replacement requests.
204+
205+
Sub-attributes marked as:
206+
- Mutability.immutable raise a ValidationError if different than the 'original' item.
207+
- Mutability.read_only are ignored
208+
"""
209+
210+
class Sub(ComplexAttribute):
211+
immutable: Annotated[Optional[str], Mutability.immutable] = None
212+
213+
class Super(Resource):
214+
schemas: Annotated[list[str], Required.true] = ["org:example:Super"]
215+
sub: Optional[Sub] = None
216+
217+
original = Super(sub=Sub(immutable="y"))
218+
assert Super.model_validate(
219+
{
220+
"sub": {
221+
"immutable": "y",
222+
}
223+
},
224+
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
225+
original=original,
226+
) == Super(
227+
schemas=["org:example:Super"],
228+
sub=Sub(
229+
immutable="y",
230+
),
231+
)
232+
233+
Super.model_validate(
234+
{
235+
"sub": {
236+
"immutable": "y",
237+
}
238+
},
239+
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
240+
original=original,
241+
)
242+
243+
with pytest.raises(
244+
ValidationError,
245+
match="Field 'immutable' is immutable but the request value is different than the original value.",
246+
):
247+
Super.model_validate(
248+
{
249+
"sub": {
250+
"immutable": "x",
251+
}
252+
},
253+
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
254+
original=original,
172255
)
173256

174257

@@ -378,12 +461,14 @@ def test_validate_creation_and_replacement_request_necessity(context):
378461
Attributes marked as:
379462
- Required.true and missing raise a ValidationError
380463
"""
464+
original = MutResource(read_only="y", read_write="y", write_only="y", immutable="y")
381465
assert ReqResource.model_validate(
382466
{
383467
"required": "x",
384468
"optional": "x",
385469
},
386470
scim_ctx=context,
471+
original=original,
387472
) == ReqResource(
388473
schemas=["org:example:ReqResource"],
389474
required="x",
@@ -395,6 +480,7 @@ def test_validate_creation_and_replacement_request_necessity(context):
395480
"required": "x",
396481
},
397482
scim_ctx=context,
483+
original=original,
398484
) == ReqResource(
399485
schemas=["org:example:ReqResource"],
400486
required="x",
@@ -408,6 +494,7 @@ def test_validate_creation_and_replacement_request_necessity(context):
408494
{
409495
"optional": "x",
410496
},
497+
original=original,
411498
scim_ctx=context,
412499
)
413500

tests/test_patch_op.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
from scim2_models import PatchOp
55
from scim2_models import PatchOperation
6-
from scim2_models.base import Context
76

87

98
def test_validate_patchop_case_insensitivith():
@@ -16,7 +15,6 @@ def test_validate_patchop_case_insensitivith():
1615
{"op": "ReMove", "path": "userName", "value": "Rivard"},
1716
],
1817
},
19-
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
2018
) == PatchOp(
2119
operations=[
2220
PatchOperation(
@@ -36,5 +34,4 @@ def test_validate_patchop_case_insensitivith():
3634
{
3735
"operations": [{"op": 42, "path": "userName", "value": "Rivard"}],
3836
},
39-
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
4037
)

0 commit comments

Comments
 (0)