Skip to content

Commit 7763ced

Browse files
committed
feat: locate validation errors with an attribute path
1 parent c69bdba commit 7763ced

7 files changed

Lines changed: 159 additions & 7 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ Changed
2121
- :attr:`AttributeBinding.urn <scim2_models.AttributeBinding.urn>` and the error messages
2222
that quote it spell the attribute as the schema declares it, such as ``userName``, whatever
2323
case the path used.
24+
- Validation errors locate the value with an attribute path, such as ``emails[1].value``, and no
25+
longer expose internal names of pydantic.
2426

2527
Security
2628
^^^^^^^^

‎scim2_models/messages/bulk.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ class BulkRequest(_ResourceParameterized, Message, Generic[ResourceT]):
226226
returned."""
227227

228228
operations: Annotated[list[BulkOperation[ResourceT]] | None, Required.true] = Field(
229-
None, serialization_alias="Operations"
229+
None, alias="Operations"
230230
)
231231
"""Defines operations within a bulk job."""
232232

@@ -243,7 +243,7 @@ class BulkResponse(_ResourceParameterized, Message, Generic[ResourceT]):
243243
__schema__ = URN("urn:ietf:params:scim:api:messages:2.0:BulkResponse")
244244

245245
operations: Annotated[list[BulkOperation[ResourceT]] | None, Required.true] = Field(
246-
None, serialization_alias="Operations"
246+
None, alias="Operations"
247247
)
248248
"""Defines operations within a bulk job."""
249249

‎scim2_models/messages/error.py‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,46 @@ def _scim_type_of(error: Mapping[str, Any]) -> str | None:
4444
return "invalidValue"
4545

4646

47+
def _is_urn(part: str | int | None) -> bool:
48+
return isinstance(part, str) and part.casefold().startswith("urn:")
49+
50+
51+
def _attribute_path(loc: Sequence[str | int]) -> str:
52+
"""Write the location of a Pydantic error as a SCIM attribute path.
53+
54+
Sub-attributes follow their attribute after a dot, and the attributes of an
55+
extension follow its schema URN after a colon, as in the notation of
56+
:rfc:`RFC7644 §3.10 <7644#section-3.10>`. SCIM has no notation for one
57+
value of a multi-valued attribute, so its index in the payload is written
58+
in brackets, e.g. ``emails[1].value``.
59+
60+
Pydantic also names the member of a union it tried. A member is a schema
61+
of its own, such as ``function-after[...]``, or the schema URN of a
62+
resource in a list. No attribute name can hold brackets or parentheses
63+
(:rfc:`RFC7643 §2.1 <7643#section-2.1>`), and no extension directly
64+
follows an index, so both are left out.
65+
"""
66+
path = ""
67+
previous: str | int | None = None
68+
for part in loc:
69+
if isinstance(part, int):
70+
path += f"[{part}]"
71+
elif "[" in part or "(" in part:
72+
continue
73+
elif _is_urn(part) and isinstance(previous, int):
74+
# The extensions of the resource may follow its own schema URN.
75+
previous = None
76+
continue
77+
elif not path:
78+
path = part
79+
elif _is_urn(previous):
80+
path += f":{part}"
81+
else:
82+
path += f".{part}"
83+
previous = part
84+
return path
85+
86+
4787
class Error(Message):
4888
"""Representation of SCIM API errors.
4989
@@ -85,8 +125,8 @@ def from_validation_error(cls, error: Mapping[str, Any]) -> "Error":
85125
detail=error["msg"],
86126
)
87127

88-
loc = ", ".join(str(loc) for loc in error["loc"])
89-
detail = f"{error['msg']}: {loc}" if loc else error["msg"]
128+
path = _attribute_path(error["loc"])
129+
detail = f"{error['msg']}: {path}" if path else error["msg"]
90130
return cls(status=400, scim_type=_scim_type_of(error), detail=detail)
91131

92132
@classmethod

‎scim2_models/messages/list_response.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def validate_cursor_chars(cls, value: str | None) -> str | None:
7575
raise InvalidCursorException().as_pydantic_error()
7676
return value
7777

78-
resources: list[AnyResource] | None = Field(None, serialization_alias="Resources")
78+
resources: list[AnyResource] | None = Field(None, alias="Resources")
7979
"""A multi-valued list of complex objects containing the requested
8080
resources."""
8181

‎scim2_models/messages/patch_op.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ def __class_getitem__(cls, item: Any) -> Any:
179179
__schema__ = URN("urn:ietf:params:scim:api:messages:2.0:PatchOp")
180180

181181
operations: Annotated[list[PatchOperation[ResourceT]] | None, Required.true] = (
182-
Field(None, serialization_alias="Operations")
182+
Field(None, alias="Operations")
183183
)
184184
"""The body of an HTTP PATCH request MUST contain the attribute
185185
"Operations", whose value is an array of one or more PATCH operations."""

‎tests/test_exceptions.py‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,23 @@
66
from pydantic import ValidationError
77
from pydantic import field_validator
88

9+
from scim2_models import BulkResponse
910
from scim2_models import Context
11+
from scim2_models import EnterpriseUser
1012
from scim2_models import Error
1113
from scim2_models import ExpiredCursorException
14+
from scim2_models import Group
1215
from scim2_models import InvalidCountException
1316
from scim2_models import InvalidCursorException
1417
from scim2_models import InvalidFilterException
1518
from scim2_models import InvalidPathException
1619
from scim2_models import InvalidSyntaxException
1720
from scim2_models import InvalidValueException
1821
from scim2_models import InvalidVersionException
22+
from scim2_models import ListResponse
1923
from scim2_models import MutabilityException
2024
from scim2_models import NoTargetException
25+
from scim2_models import PatchOp
2126
from scim2_models import PathNotFoundException
2227
from scim2_models import SCIMException
2328
from scim2_models import SensitiveException
@@ -616,3 +621,108 @@ def test_from_error_without_status():
616621
error = Error(scim_type="uniqueness", detail="Duplicate userName")
617622
exc = SCIMException.from_error(error)
618623
assert exc.status == 409
624+
625+
626+
ENTERPRISE_USER_SCHEMA = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
627+
USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
628+
629+
630+
def first_error_detail(model, payload, scim_ctx):
631+
"""Return the detail of the first SCIM error a payload raises."""
632+
with pytest.raises(ValidationError) as exc_info:
633+
model.model_validate(payload, scim_ctx=scim_ctx)
634+
return Error.from_validation_errors(exc_info.value)[0].detail
635+
636+
637+
@pytest.mark.parametrize(
638+
"model,payload,scim_ctx,path",
639+
[
640+
(
641+
User,
642+
{"userName": "bjensen", "name": {"givenName": 3}},
643+
Context.RESOURCE_CREATION_REQUEST,
644+
"name.givenName",
645+
),
646+
(
647+
User,
648+
{
649+
"userName": "bjensen",
650+
"emails": [{"value": "a@example.com"}, {"value": 3}],
651+
},
652+
Context.RESOURCE_CREATION_REQUEST,
653+
"emails[1].value",
654+
),
655+
(
656+
User[EnterpriseUser],
657+
{"userName": "bjensen", ENTERPRISE_USER_SCHEMA: {"manager": {"value": 3}}},
658+
Context.RESOURCE_CREATION_REQUEST,
659+
f"{ENTERPRISE_USER_SCHEMA}:manager.value",
660+
),
661+
(
662+
ListResponse[User | Group],
663+
{"Resources": [{"schemas": [USER_SCHEMA], "id": "1", "userName": 3}]},
664+
Context.RESOURCE_QUERY_RESPONSE,
665+
"Resources[0].userName",
666+
),
667+
(
668+
ListResponse[User[EnterpriseUser] | Group],
669+
{
670+
"Resources": [
671+
{
672+
"schemas": [USER_SCHEMA, ENTERPRISE_USER_SCHEMA],
673+
"id": "1",
674+
"userName": "bjensen",
675+
ENTERPRISE_USER_SCHEMA: {"employeeNumber": 3},
676+
}
677+
]
678+
},
679+
Context.RESOURCE_QUERY_RESPONSE,
680+
f"Resources[0].{ENTERPRISE_USER_SCHEMA}:employeeNumber",
681+
),
682+
(
683+
BulkResponse[User],
684+
{
685+
"Operations": [
686+
{
687+
"method": "POST",
688+
"status": 201,
689+
"location": "https://example.com/v2/Users/1",
690+
"response": {"schemas": [USER_SCHEMA], "userName": 3},
691+
}
692+
]
693+
},
694+
Context.BULK_RESPONSE,
695+
"Operations[0].response.userName",
696+
),
697+
],
698+
)
699+
def test_the_detail_of_an_error_locates_it_with_an_attribute_path(
700+
model, payload, scim_ctx, path
701+
):
702+
"""Sub-attributes follow a dot, extension attributes their schema URN and a colon, values their index.
703+
704+
The members of the unions pydantic tried are not part of the payload, and
705+
are left out.
706+
"""
707+
assert first_error_detail(model, payload, scim_ctx) == (
708+
f"Input should be a valid string: {path}"
709+
)
710+
711+
712+
def test_the_detail_of_an_error_at_the_root_of_a_payload_carries_no_path():
713+
"""An error on the payload itself has no attribute to point at."""
714+
with pytest.raises(ValidationError) as exc_info:
715+
User.model_validate("not an object", scim_ctx=Context.RESOURCE_CREATION_REQUEST)
716+
error = Error.from_validation_errors(exc_info.value)[0]
717+
assert error.detail == "Input should be a valid dictionary or instance of User"
718+
719+
720+
@pytest.mark.parametrize("key", ["Operations", "operations", "OPERATIONS"])
721+
def test_message_attributes_keep_their_case_insensitive_names(key):
722+
"""The Operations of a message are named as RFC 7644 spells them, and read whatever their case."""
723+
patch = PatchOp[User].model_validate(
724+
{key: [{"op": "replace", "path": "nickName", "value": "Babs"}]},
725+
scim_ctx=Context.RESOURCE_PATCH_REQUEST,
726+
)
727+
assert patch.operations[0].value == "Babs"
728+
assert "Operations" in patch.model_dump(scim_ctx=Context.RESOURCE_PATCH_REQUEST)

‎tests/test_path.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def test_a_malformed_path_in_a_message_answers_invalid_path():
103103
)
104104

105105
reported = raised.value.errors()[0]
106-
assert reported["loc"] == ("operations", 0, "path")
106+
assert reported["loc"] == ("Operations", 0, "path")
107107
error = Error.from_validation_error(reported)
108108
assert error.status == 400
109109
assert error.scim_type == "invalidPath"

0 commit comments

Comments
 (0)