Skip to content

Commit 895dc3d

Browse files
committed
fix: allow patch operations on resources and extensions path root
1 parent 7fdcb76 commit 895dc3d

File tree

4 files changed

+101
-7
lines changed

4 files changed

+101
-7
lines changed

doc/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Changelog
77
Fixed
88
^^^^^
99
- Attributes with ``None`` type are excluded from Schema generation.
10+
- Allow PATCH operations on resources and extensions root path.
1011

1112
[0.4.2] - 2025-08-05
1213
--------------------

scim2_models/messages/patch_op.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,9 +334,18 @@ def _set_value_at_path(
334334
"""Set a value at a specific path."""
335335
target, attr_path = _resolve_path_to_target(resource, path)
336336

337-
if not attr_path or not target:
337+
if not target:
338338
raise ValueError(Error.make_invalid_path_error().detail)
339339

340+
if not attr_path:
341+
if not isinstance(value, dict):
342+
raise ValueError(Error.make_invalid_path_error().detail)
343+
344+
updated_data = {**target.model_dump(), **value}
345+
updated_target = type(target).model_validate(updated_data)
346+
target.__dict__.update(updated_target.__dict__)
347+
return True
348+
340349
path_parts = attr_path.split(".")
341350
if len(path_parts) == 1:
342351
return cls._set_simple_attribute(target, path_parts[0], value, is_add)

scim2_models/urn.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ def _normalize_path(model: Optional[type["BaseModel"]], path: str) -> tuple[str,
2929

3030
# Absolute URN
3131
if ":" in path:
32+
if (
33+
model
34+
and issubclass(model, Resource)
35+
and (
36+
path in model.get_extension_models()
37+
or path == model.model_fields["schemas"].default[0]
38+
)
39+
):
40+
return path, ""
41+
3242
parts = path.rsplit(":", 1)
3343
return parts[0], parts[1]
3444

@@ -100,12 +110,13 @@ def _resolve_path_to_target(
100110
if not schema_urn:
101111
return resource, attr_path
102112

113+
if extension_class := resource.get_extension_model(schema_urn):
114+
extension_instance = _get_or_create_extension_instance(
115+
resource, extension_class
116+
)
117+
return extension_instance, attr_path
118+
103119
if schema_urn in resource.schemas:
104120
return resource, attr_path
105121

106-
extension_class = resource.get_extension_model(schema_urn)
107-
if not extension_class:
108-
return (None, "")
109-
110-
extension_instance = _get_or_create_extension_instance(resource, extension_class)
111-
return extension_instance, attr_path
122+
return (None, "")

tests/test_patch_op_extensions.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,76 @@ def test_complex_object_creation_and_basemodel_matching():
333333

334334
result = patch.patch(group)
335335
assert result is True
336+
337+
338+
def test_patch_extension_schema_path_without_attribute():
339+
"""Test PATCH with extension schema URN as path (no specific attribute)."""
340+
user = User[EnterpriseUser](
341+
user_name="test",
342+
schemas=[
343+
"urn:ietf:params:scim:schemas:core:2.0:User",
344+
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
345+
],
346+
)
347+
user[EnterpriseUser] = EnterpriseUser()
348+
349+
patch = PatchOp[User](
350+
operations=[
351+
PatchOperation(
352+
op=PatchOperation.Op.add,
353+
path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
354+
value={
355+
"costCenter": "Engineering",
356+
"department": "IT",
357+
"employeeNumber": "12345",
358+
},
359+
)
360+
]
361+
)
362+
363+
result = patch.patch(user)
364+
assert result is True
365+
assert user[EnterpriseUser].cost_center == "Engineering"
366+
367+
368+
def test_patch_main_schema_path_without_attribute():
369+
"""Test PATCH with main schema URN as path (no specific attribute)."""
370+
user = User(user_name="original")
371+
372+
patch = PatchOp[User](
373+
operations=[
374+
PatchOperation(
375+
op=PatchOperation.Op.add,
376+
path="urn:ietf:params:scim:schemas:core:2.0:User",
377+
value={
378+
"displayName": "Updated Name",
379+
"nickName": "Nick",
380+
"title": "Manager",
381+
},
382+
)
383+
]
384+
)
385+
386+
result = patch.patch(user)
387+
assert result is True
388+
assert user.display_name == "Updated Name"
389+
assert user.nick_name == "Nick"
390+
assert user.title == "Manager"
391+
392+
393+
def test_patch_schema_path_with_invalid_value_type():
394+
"""Test PATCH with schema URN path and invalid value type (non-dict)."""
395+
user = User(user_name="test")
396+
397+
patch = PatchOp[User](
398+
operations=[
399+
PatchOperation(
400+
op=PatchOperation.Op.add,
401+
path="urn:ietf:params:scim:schemas:core:2.0:User",
402+
value="invalid string value",
403+
)
404+
]
405+
)
406+
407+
with pytest.raises(ValueError, match="path.*invalid.*malformed"):
408+
patch.patch(user)

0 commit comments

Comments
 (0)