Skip to content

Commit 91120a5

Browse files
authored
feat: scim-aware model_validate_json (#151)
1 parent 5b1fd2c commit 91120a5

5 files changed

Lines changed: 142 additions & 21 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ Added
1010
They look models up by schema, accept any sequence of :class:`~scim2_models.ScimObject` subclasses,
1111
and reflect the input types in the returned type.
1212
- :class:`~scim2_models.ScimObject` and ``AnyScimObject`` are exposed in the public API, so that downstream projects can annotate values that are either resources or messages.
13+
- :meth:`~scim2_models.BaseModel.model_validate_json` takes a ``scim_ctx`` parameter, like the
14+
other validation and serialization methods, so JSON payloads can be validated without being
15+
decoded first. :issue:`150`
1316

1417
Changed
1518
^^^^^^^

‎doc/tutorial.rst‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ Use Pydantic's :func:`~scim2_models.BaseModel.model_validate` method to parse an
5555
>>> user.meta.created # doctest: +ELLIPSIS
5656
datetime.datetime(2010, 1, 23, 4, 56, 22, tzinfo=...)
5757
58+
Payloads that have not been decoded yet can be handled by
59+
:func:`~scim2_models.BaseModel.model_validate_json`.
60+
Malformed JSON raises a :class:`~pydantic.ValidationError`, like any other invalid payload.
61+
62+
.. code-block:: python
63+
64+
>>> import json
65+
66+
>>> user = User.model_validate_json(json.dumps(payload))
67+
>>> user.user_name
68+
'bjensen@example.com'
69+
5870
5971
Model serialization
6072
===================
@@ -124,6 +136,8 @@ fields with unexpected values will raise :class:`~pydantic.ValidationError`:
124136
... except ValidationError:
125137
... obj = Error(...)
126138
139+
:meth:`~scim2_models.BaseModel.model_validate_json` takes the same :paramref:`~scim2_models.BaseModel.model_validate_json.scim_ctx` parameter.
140+
127141
Context annotations
128142
===================
129143

‎scim2_models/base.py‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,18 @@ def _scim_response_serializer(
676676
):
677677
del serialized[alias]
678678

679+
@classmethod
680+
def _prepare_model_validate(
681+
cls,
682+
scim_ctx: Context | None = Context.DEFAULT,
683+
original: Optional["BaseModel"] = None,
684+
**kwargs: Any,
685+
) -> dict[str, Any]:
686+
context = kwargs.setdefault("context", {})
687+
context.setdefault("scim", scim_ctx)
688+
context.setdefault("original", original)
689+
return kwargs
690+
679691
@classmethod
680692
def model_validate(
681693
cls,
@@ -704,11 +716,25 @@ def model_validate(
704716
stacklevel=2,
705717
)
706718

707-
context = kwargs.setdefault("context", {})
708-
context.setdefault("scim", scim_ctx)
709-
context.setdefault("original", original)
719+
validate_kwargs = cls._prepare_model_validate(scim_ctx, original, **kwargs)
720+
return super().model_validate(*args, **validate_kwargs)
721+
722+
@classmethod
723+
def model_validate_json(
724+
cls,
725+
*args: Any,
726+
scim_ctx: Context | None = Context.DEFAULT,
727+
**kwargs: Any,
728+
) -> Self:
729+
"""Validate SCIM JSON payloads and generate model representation by using Pydantic :meth:`~pydantic.BaseModel.model_validate_json`.
710730
711-
return super().model_validate(*args, **kwargs)
731+
Malformed JSON payloads raise a :class:`~pydantic.ValidationError`, like
732+
any other SCIM validation failure.
733+
734+
:param scim_ctx: The SCIM :class:`~scim2_models.Context` in which the validation happens.
735+
"""
736+
validate_kwargs = cls._prepare_model_validate(scim_ctx, **kwargs)
737+
return super().model_validate_json(*args, **validate_kwargs)
712738

713739
def _prepare_model_dump(
714740
self,

‎tests/test_model_validation.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,3 +746,49 @@ def test_validate_query_and_search_request_necessity(context):
746746
id="x",
747747
optional="x",
748748
)
749+
750+
751+
def test_validate_json_payload():
752+
"""JSON payloads are validated like already decoded payloads."""
753+
assert MutResource.model_validate_json('{"readWrite": "x"}') == MutResource(
754+
schemas=["org:example:MutResource"],
755+
readWrite="x",
756+
)
757+
758+
759+
def test_validate_json_bytes_payload():
760+
"""JSON payloads can be passed as bytes."""
761+
assert MutResource.model_validate_json(b'{"readWrite": "x"}') == MutResource(
762+
schemas=["org:example:MutResource"],
763+
readWrite="x",
764+
)
765+
766+
767+
def test_validate_json_applies_the_scim_context():
768+
"""The SCIM context drives the validation of JSON payloads."""
769+
with pytest.raises(
770+
ValidationError,
771+
match="Field 'write_only' has mutability 'writeOnly' but this in not valid in resource query request context",
772+
):
773+
MutResource.model_validate_json(
774+
'{"writeOnly": "x"}',
775+
scim_ctx=Context.RESOURCE_QUERY_REQUEST,
776+
)
777+
778+
779+
def test_validate_json_with_an_explicit_validation_context():
780+
"""An explicit Pydantic validation context takes precedence over the SCIM context."""
781+
with pytest.raises(
782+
ValidationError,
783+
match="Field 'write_only' has mutability 'writeOnly' but this in not valid in resource query request context",
784+
):
785+
MutResource.model_validate_json(
786+
'{"writeOnly": "x"}',
787+
context={"scim": Context.RESOURCE_QUERY_REQUEST},
788+
)
789+
790+
791+
def test_validate_json_rejects_malformed_payloads():
792+
"""Malformed JSON payloads raise a ValidationError like any invalid payload."""
793+
with pytest.raises(ValidationError, match="Invalid JSON"):
794+
MutResource.model_validate_json("{invalid")

‎tests/test_models.py‎

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import json
12
import os
23

34
import pytest
5+
from pydantic import ValidationError
46

57
from scim2_models import BulkRequest
68
from scim2_models import BulkResponse
@@ -19,28 +21,32 @@
1921
from scim2_models import get_model_by_schema
2022

2123

24+
def _error_summary(exc: ValidationError) -> list[tuple[str, tuple]]:
25+
return [(error["type"], error["loc"]) for error in exc.errors()]
26+
27+
28+
SAMPLE_MODELS = {
29+
"user": User,
30+
"enterprise_user": User[EnterpriseUser],
31+
"group": Group,
32+
"schema": Schema,
33+
"resource_type": ResourceType,
34+
"service_provider_configuration": ServiceProviderConfig,
35+
"list_response": ListResponse[User[EnterpriseUser] | Group | Schema | ResourceType],
36+
"patch_op": PatchOp[User],
37+
"bulk_request": BulkRequest,
38+
"bulk_response": BulkResponse,
39+
"search_request": SearchRequest,
40+
"error": Error,
41+
}
42+
43+
2244
def test_parse_and_serialize_examples(load_sample):
2345
samples = list(os.walk("samples"))[0][2]
24-
models = {
25-
"user": User,
26-
"enterprise_user": User[EnterpriseUser],
27-
"group": Group,
28-
"schema": Schema,
29-
"resource_type": ResourceType,
30-
"service_provider_configuration": ServiceProviderConfig,
31-
"list_response": ListResponse[
32-
User[EnterpriseUser] | Group | Schema | ResourceType
33-
],
34-
"patch_op": PatchOp[User],
35-
"bulk_request": BulkRequest,
36-
"bulk_response": BulkResponse,
37-
"search_request": SearchRequest,
38-
"error": Error,
39-
}
4046

4147
for sample in samples:
4248
model_name = sample.replace(".json", "").split("-")[2]
43-
model = models[model_name]
49+
model = SAMPLE_MODELS[model_name]
4450

4551
skipped = [
4652
# resources without schemas are not yet supported
@@ -76,6 +82,32 @@ def test_parse_and_serialize_examples(load_sample):
7682
assert obj.model_dump(exclude_unset=True) == payload
7783

7884

85+
def test_parse_json_and_decoded_examples(load_sample):
86+
"""JSON payloads and already decoded payloads are validated the same way."""
87+
samples = list(os.walk("samples"))[0][2]
88+
89+
for sample in samples:
90+
model_name = sample.replace(".json", "").split("-")[2]
91+
model = SAMPLE_MODELS[model_name]
92+
93+
payload = load_sample(sample)
94+
raw = json.dumps(payload)
95+
96+
try:
97+
obj = model.model_validate(payload)
98+
except ValidationError as exc:
99+
with pytest.raises(ValidationError) as json_exc:
100+
model.model_validate_json(raw)
101+
assert _error_summary(json_exc.value) == _error_summary(exc)
102+
continue
103+
104+
json_obj = model.model_validate_json(raw)
105+
assert obj == json_obj
106+
assert obj.model_dump(exclude_unset=True) == json_obj.model_dump(
107+
exclude_unset=True
108+
)
109+
110+
79111
def test_get_model_by_schema():
80112
resource_types = [Group, User[EnterpriseUser]]
81113
assert (

0 commit comments

Comments
 (0)