Skip to content

Commit 7a7a044

Browse files
authored
Merge pull request #101 from python-scim/invalid-attributes
Ignore invalid attributes and excluded_attributes on serialization
2 parents d33c073 + 038e501 commit 7a7a044

File tree

6 files changed

+161
-159
lines changed

6 files changed

+161
-159
lines changed

doc/changelog.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ Added
88
^^^^^
99
- Proper path validation for :attr:`~scim2_models.SearchRequest.attributes`, :attr:`~scim2_models.SearchRequest.excluded_attributes` and :attr:`~scim2_models.SearchRequest.sort_by`.
1010

11+
Fixed
12+
^^^^^
13+
- When using ``model_dump``, ignore invalid ``attributes`` and ``excluded_attributes``
14+
as suggested by RFC7644.
15+
1116
[0.3.7] - 2025-07-17
1217
--------------------
1318

scim2_models/rfc7643/resource.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from ..context import Context
2929
from ..reference import Reference
3030
from ..scim_object import ScimObject
31-
from ..scim_object import validate_attribute_urn
31+
from ..urn import validate_attribute_urn
3232
from ..utils import UNION_TYPES
3333
from ..utils import normalize_attribute_name
3434

@@ -305,13 +305,19 @@ def _prepare_model_dump(
305305
**kwargs: Any,
306306
) -> dict[str, Any]:
307307
kwargs = super()._prepare_model_dump(scim_ctx, **kwargs)
308+
309+
# RFC 7644: "SHOULD ignore any query parameters they do not recognize"
308310
kwargs["context"]["scim_attributes"] = [
309-
validate_attribute_urn(attribute, self.__class__)
311+
valid_attr
310312
for attribute in (attributes or [])
313+
if (valid_attr := validate_attribute_urn(attribute, self.__class__))
314+
is not None
311315
]
312316
kwargs["context"]["scim_excluded_attributes"] = [
313-
validate_attribute_urn(attribute, self.__class__)
317+
valid_attr
314318
for attribute in (excluded_attributes or [])
319+
if (valid_attr := validate_attribute_urn(attribute, self.__class__))
320+
is not None
315321
]
316322
return kwargs
317323

scim2_models/scim_object.py

Lines changed: 1 addition & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -8,86 +8,9 @@
88
from .annotations import Required
99
from .base import BaseModel
1010
from .context import Context
11-
from .utils import normalize_attribute_name
1211

1312
if TYPE_CHECKING:
14-
from .rfc7643.resource import Resource
15-
16-
17-
def validate_model_attribute(model: type["BaseModel"], attribute_base: str) -> None:
18-
"""Validate that an attribute name or a sub-attribute path exist for a given model."""
19-
attribute_name, *sub_attribute_blocks = attribute_base.split(".")
20-
sub_attribute_base = ".".join(sub_attribute_blocks)
21-
22-
aliases = {field.validation_alias for field in model.model_fields.values()}
23-
24-
if normalize_attribute_name(attribute_name) not in aliases:
25-
raise ValueError(
26-
f"Model '{model.__name__}' has no attribute named '{attribute_name}'"
27-
)
28-
29-
if sub_attribute_base:
30-
attribute_type = model.get_field_root_type(attribute_name)
31-
32-
if not attribute_type or not issubclass(attribute_type, BaseModel):
33-
raise ValueError(
34-
f"Attribute '{attribute_name}' is not a complex attribute, and cannot have a '{sub_attribute_base}' sub-attribute"
35-
)
36-
37-
validate_model_attribute(attribute_type, sub_attribute_base)
38-
39-
40-
def extract_schema_and_attribute_base(attribute_urn: str) -> tuple[str, str]:
41-
"""Extract the schema urn part and the attribute name part from attribute name.
42-
43-
As defined in :rfc:`RFC7644 §3.10 <7644#section-3.10>`.
44-
"""
45-
*urn_blocks, attribute_base = attribute_urn.split(":")
46-
schema = ":".join(urn_blocks)
47-
return schema, attribute_base
48-
49-
50-
def validate_attribute_urn(
51-
attribute_name: str,
52-
default_resource: Optional[type["Resource"]] = None,
53-
resource_types: Optional[list[type["Resource"]]] = None,
54-
) -> str:
55-
"""Validate that an attribute urn is valid or not.
56-
57-
:param attribute_name: The attribute urn to check.
58-
:default_resource: The default resource if `attribute_name` is not an absolute urn.
59-
:resource_types: The available resources in which to look for the attribute.
60-
:return: The normalized attribute URN.
61-
"""
62-
from .rfc7643.resource import Resource
63-
64-
if not resource_types:
65-
resource_types = []
66-
67-
if default_resource and default_resource not in resource_types:
68-
resource_types.append(default_resource)
69-
70-
default_schema = (
71-
default_resource.model_fields["schemas"].default[0]
72-
if default_resource
73-
else None
74-
)
75-
76-
schema: Optional[Any]
77-
schema, attribute_base = extract_schema_and_attribute_base(attribute_name)
78-
if not schema:
79-
schema = default_schema
80-
81-
if not schema:
82-
raise ValueError("No default schema and relative URN")
83-
84-
resource = Resource.get_by_schema(resource_types, schema)
85-
if not resource:
86-
raise ValueError(f"No resource matching schema '{schema}'")
87-
88-
validate_model_attribute(resource, attribute_base)
89-
90-
return f"{schema}:{attribute_base}"
13+
pass
9114

9215

9316
class ScimObject(BaseModel):

scim2_models/urn.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from typing import TYPE_CHECKING
2+
from typing import Any
3+
from typing import Optional
4+
5+
from .base import BaseModel
6+
from .utils import normalize_attribute_name
7+
8+
if TYPE_CHECKING:
9+
from .base import BaseModel
10+
from .rfc7643.resource import Resource
11+
12+
13+
def normalize_path(model: type["BaseModel"], path: str) -> tuple[str, str]:
14+
"""Resolve a path to (schema_urn, attribute_path)."""
15+
# Absolute URN
16+
if ":" in path:
17+
parts = path.rsplit(":", 1)
18+
return parts[0], parts[1]
19+
20+
schemas_field = model.model_fields.get("schemas")
21+
return schemas_field.default[0], path # type: ignore
22+
23+
24+
def validate_model_attribute(model: type["BaseModel"], attribute_base: str) -> None:
25+
"""Validate that an attribute name or a sub-attribute path exist for a given model."""
26+
attribute_name, *sub_attribute_blocks = attribute_base.split(".")
27+
sub_attribute_base = ".".join(sub_attribute_blocks)
28+
29+
aliases = {field.validation_alias for field in model.model_fields.values()}
30+
31+
if normalize_attribute_name(attribute_name) not in aliases:
32+
raise ValueError(
33+
f"Model '{model.__name__}' has no attribute named '{attribute_name}'"
34+
)
35+
36+
if sub_attribute_base:
37+
attribute_type = model.get_field_root_type(attribute_name)
38+
39+
if not attribute_type or not issubclass(attribute_type, BaseModel):
40+
raise ValueError(
41+
f"Attribute '{attribute_name}' is not a complex attribute, and cannot have a '{sub_attribute_base}' sub-attribute"
42+
)
43+
44+
validate_model_attribute(attribute_type, sub_attribute_base)
45+
46+
47+
def validate_attribute_urn(
48+
attribute_name: str, resource: type["Resource"]
49+
) -> Optional[str]:
50+
"""Validate that an attribute urn is valid or not.
51+
52+
:param attribute_name: The attribute urn to check.
53+
:return: The normalized attribute URN.
54+
"""
55+
from .rfc7643.resource import Resource
56+
57+
schema: Optional[Any]
58+
schema, attribute_base = normalize_path(resource, attribute_name)
59+
60+
validated_resource = Resource.get_by_schema([resource], schema)
61+
if not validated_resource:
62+
return None
63+
64+
try:
65+
validate_model_attribute(validated_resource, attribute_base)
66+
except ValueError:
67+
return None
68+
69+
return f"{schema}:{attribute_base}"

tests/test_model_attributes.py

Lines changed: 10 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
from typing import Annotated
33
from typing import Optional
44

5-
import pytest
6-
75
from scim2_models.annotations import Required
86
from scim2_models.annotations import Returned
97
from scim2_models.attributes import ComplexAttribute
@@ -15,7 +13,7 @@
1513
from scim2_models.rfc7643.resource import Resource
1614
from scim2_models.rfc7643.user import User
1715
from scim2_models.rfc7644.error import Error
18-
from scim2_models.scim_object import validate_attribute_urn
16+
from scim2_models.urn import validate_attribute_urn
1917

2018

2119
class Sub(ComplexAttribute):
@@ -74,32 +72,18 @@ def test_validate_attribute_urn():
7472
validate_attribute_urn("urn:example:2.0:Foo:bar", Foo)
7573
== "urn:example:2.0:Foo:bar"
7674
)
77-
assert (
78-
validate_attribute_urn("urn:example:2.0:Foo:bar", User, resource_types=[Foo])
79-
== "urn:example:2.0:Foo:bar"
80-
)
8175

8276
assert validate_attribute_urn("sub", Foo) == "urn:example:2.0:Foo:sub"
8377
assert (
8478
validate_attribute_urn("urn:example:2.0:Foo:sub", Foo)
8579
== "urn:example:2.0:Foo:sub"
8680
)
87-
assert (
88-
validate_attribute_urn("urn:example:2.0:Foo:sub", User, resource_types=[Foo])
89-
== "urn:example:2.0:Foo:sub"
90-
)
9181

9282
assert validate_attribute_urn("sub.always", Foo) == "urn:example:2.0:Foo:sub.always"
9383
assert (
9484
validate_attribute_urn("urn:example:2.0:Foo:sub.always", Foo)
9585
== "urn:example:2.0:Foo:sub.always"
9686
)
97-
assert (
98-
validate_attribute_urn(
99-
"urn:example:2.0:Foo:sub.always", User, resource_types=[Foo]
100-
)
101-
== "urn:example:2.0:Foo:sub.always"
102-
)
10387

10488
assert validate_attribute_urn("snakeCase", Foo) == "urn:example:2.0:Foo:snakeCase"
10589
assert (
@@ -111,37 +95,18 @@ def test_validate_attribute_urn():
11195
validate_attribute_urn("urn:example:2.0:MyExtension:baz", Foo[MyExtension])
11296
== "urn:example:2.0:MyExtension:baz"
11397
)
98+
99+
assert validate_attribute_urn("urn:InvalidResource:bar", Foo) is None
100+
101+
assert validate_attribute_urn("urn:example:2.0:Foo:invalid", Foo) is None
102+
103+
assert validate_attribute_urn("bar.invalid", Foo) is None
104+
114105
assert (
115-
validate_attribute_urn(
116-
"urn:example:2.0:MyExtension:baz", resource_types=[Foo[MyExtension]]
117-
)
118-
== "urn:example:2.0:MyExtension:baz"
106+
validate_attribute_urn("urn:example:2.0:MyExtension:invalid", Foo[MyExtension])
107+
is None
119108
)
120109

121-
with pytest.raises(ValueError, match="No default schema and relative URN"):
122-
validate_attribute_urn("bar", resource_types=[Foo])
123-
124-
with pytest.raises(
125-
ValueError, match="No resource matching schema 'urn:InvalidResource'"
126-
):
127-
validate_attribute_urn("urn:InvalidResource:bar", Foo)
128-
129-
with pytest.raises(
130-
ValueError, match="No resource matching schema 'urn:example:2.0:Foo'"
131-
):
132-
validate_attribute_urn("urn:example:2.0:Foo:bar")
133-
134-
with pytest.raises(
135-
ValueError, match="Model 'Foo' has no attribute named 'invalid'"
136-
):
137-
validate_attribute_urn("urn:example:2.0:Foo:invalid", Foo)
138-
139-
with pytest.raises(
140-
ValueError,
141-
match="Attribute 'bar' is not a complex attribute, and cannot have a 'invalid' sub-attribute",
142-
):
143-
validate_attribute_urn("bar.invalid", Foo)
144-
145110

146111
def test_payload_attribute_case_sensitivity():
147112
"""RFC7643 §2.1 indicates that attribute names should be case insensitive.

0 commit comments

Comments
 (0)