Skip to content

Commit 488f9ba

Browse files
committed
feat: add Resource.replace() for PUT immutability verification
Extract public replace() method on Resource to verify that immutable fields have not been modified, per RFC 7644 §3.5.1. Deprecate the 'original' parameter of model_validate().
1 parent 7fa466d commit 488f9ba

File tree

13 files changed

+279
-168
lines changed

13 files changed

+279
-168
lines changed

doc/changelog.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ Changelog
44
[0.6.8] - Unreleased
55
--------------------
66

7+
Added
8+
^^^^^
9+
- :class:`~scim2_models.MutabilityException` handler in framework integration examples (FastAPI, Flask, Django).
10+
11+
Deprecated
12+
^^^^^^^^^^
13+
- The ``original`` parameter of :meth:`~scim2_models.base.BaseModel.model_validate` is deprecated. Use :meth:`~scim2_models.Resource.replace` on the validated instance instead. Will be removed in 0.8.0.
14+
715
Fixed
816
^^^^^
917
- PATCH operations on :attr:`~scim2_models.Mutability.immutable` fields are now validated at runtime per :rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`: ``add`` is only allowed when the field has no previous value, ``replace`` is only allowed with the same value, and ``remove`` is only allowed on unset fields.

doc/guides/_examples/django_example.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
from scim2_models import PatchOp
1717
from scim2_models import ResourceType
1818
from scim2_models import ResponseParameters
19+
from scim2_models import SCIMException
1920
from scim2_models import Schema
2021
from scim2_models import SearchRequest
21-
from scim2_models import UniquenessException
2222
from scim2_models import User
2323

2424
from .integrations import check_etag
@@ -81,12 +81,12 @@ def scim_validation_error(error):
8181
# -- validation-helper-end --
8282

8383

84-
# -- uniqueness-helper-start --
85-
def scim_uniqueness_error(error):
86-
"""Turn uniqueness errors into a SCIM 409 response."""
87-
scim_error = UniquenessException(detail=str(error)).to_error()
88-
return scim_response(scim_error.model_dump_json(), HTTPStatus.CONFLICT)
89-
# -- uniqueness-helper-end --
84+
# -- scim-exception-helper-start --
85+
def scim_exception_error(error):
86+
"""Turn SCIM exceptions into a SCIM error response."""
87+
scim_error = error.to_error()
88+
return scim_response(scim_error.model_dump_json(), scim_error.status)
89+
# -- scim-exception-helper-end --
9090

9191

9292
# -- precondition-helper-start --
@@ -152,17 +152,19 @@ def put(self, request, app_record):
152152
replacement = User.model_validate(
153153
json.loads(request.body),
154154
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
155-
original=existing_user,
156155
)
156+
replacement.replace(existing_user)
157157
except ValidationError as error:
158158
return scim_validation_error(error)
159+
except SCIMException as error:
160+
return scim_exception_error(error)
159161

160162
replacement.id = existing_user.id
161163
updated_record = from_scim_user(replacement)
162164
try:
163165
save_record(updated_record)
164-
except ValueError as error:
165-
return scim_uniqueness_error(error)
166+
except SCIMException as error:
167+
return scim_exception_error(error)
166168

167169
response_user = to_scim_user(updated_record, resource_location(request, updated_record))
168170
resp = scim_response(
@@ -192,8 +194,8 @@ def patch(self, request, app_record):
192194
updated_record = from_scim_user(scim_user)
193195
try:
194196
save_record(updated_record)
195-
except ValueError as error:
196-
return scim_uniqueness_error(error)
197+
except SCIMException as error:
198+
return scim_exception_error(error)
197199

198200
resp = scim_response(
199201
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE)
@@ -242,8 +244,8 @@ def post(self, request):
242244
app_record = from_scim_user(request_user)
243245
try:
244246
save_record(app_record)
245-
except ValueError as error:
246-
return scim_uniqueness_error(error)
247+
except SCIMException as error:
248+
return scim_exception_error(error)
247249

248250
response_user = to_scim_user(app_record, resource_location(request, app_record))
249251
resp = scim_response(

doc/guides/_examples/fastapi_example.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
from scim2_models import PatchOp
1515
from scim2_models import ResourceType
1616
from scim2_models import ResponseParameters
17+
from scim2_models import SCIMException
1718
from scim2_models import Schema
1819
from scim2_models import SearchRequest
19-
from scim2_models import UniquenessException
2020
from scim2_models import User
2121

2222
from .integrations import PreconditionFailed
@@ -79,11 +79,11 @@ async def handle_http_exception(request, error):
7979
return Response(scim_error.model_dump_json(), status_code=error.status_code)
8080

8181

82-
@app.exception_handler(ValueError)
83-
async def handle_value_error(request, error):
84-
"""Turn uniqueness errors into SCIM 409 responses."""
85-
scim_error = UniquenessException(detail=str(error)).to_error()
86-
return Response(scim_error.model_dump_json(), status_code=HTTPStatus.CONFLICT)
82+
@app.exception_handler(SCIMException)
83+
async def handle_scim_error(request, error):
84+
"""Turn SCIM exceptions into SCIM error responses."""
85+
scim_error = error.to_error()
86+
return Response(scim_error.model_dump_json(), status_code=scim_error.status)
8787

8888

8989
@app.exception_handler(PreconditionFailed)
@@ -151,8 +151,8 @@ async def replace_user(request: Request, app_record: dict = Depends(resolve_user
151151
replacement = User.model_validate(
152152
await request.json(),
153153
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
154-
original=existing_user,
155154
)
155+
replacement.replace(existing_user)
156156

157157
replacement.id = existing_user.id
158158
updated_record = from_scim_user(replacement)

doc/guides/_examples/flask_example.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
from scim2_models import PatchOp
1515
from scim2_models import ResourceType
1616
from scim2_models import ResponseParameters
17+
from scim2_models import SCIMException
1718
from scim2_models import Schema
1819
from scim2_models import SearchRequest
19-
from scim2_models import UniquenessException
2020
from scim2_models import User
2121

2222
from .integrations import check_etag
@@ -87,11 +87,11 @@ def handle_not_found(error):
8787
return scim_error.model_dump_json(), HTTPStatus.NOT_FOUND
8888

8989

90-
@bp.errorhandler(ValueError)
91-
def handle_value_error(error):
92-
"""Turn uniqueness errors into SCIM 409 responses."""
93-
scim_error = UniquenessException(detail=str(error)).to_error()
94-
return scim_error.model_dump_json(), HTTPStatus.CONFLICT
90+
@bp.errorhandler(SCIMException)
91+
def handle_scim_error(error):
92+
"""Turn SCIM exceptions into SCIM error responses."""
93+
scim_error = error.to_error()
94+
return scim_error.model_dump_json(), scim_error.status
9595

9696

9797
@bp.errorhandler(PreconditionFailed)
@@ -156,8 +156,8 @@ def replace_user(app_record):
156156
replacement = User.model_validate(
157157
request.get_json(),
158158
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
159-
original=existing_user,
160159
)
160+
replacement.replace(existing_user)
161161

162162
replacement.id = existing_user.id
163163
updated_record = from_scim_user(replacement)

doc/guides/_examples/integrations.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from scim2_models import ResourceType
1616
from scim2_models import ServiceProviderConfig
1717
from scim2_models import Sort
18+
from scim2_models import UniquenessException
1819
from scim2_models import User
1920

2021
# -- storage-start --
@@ -40,12 +41,14 @@ def list_records(start=None, stop=None):
4041

4142

4243
def save_record(record):
43-
"""Persist *record*, raising ValueError if its userName is already taken."""
44+
"""Persist *record*, raising UniquenessException if its userName is already taken."""
4445
if not record.get("id"):
4546
record["id"] = str(uuid4())
4647
for existing in records.values():
4748
if existing["id"] != record["id"] and existing["user_name"] == record["user_name"]:
48-
raise ValueError(f"userName {record['user_name']!r} is already taken")
49+
raise UniquenessException(
50+
detail=f"userName {record['user_name']!r} is already taken"
51+
)
4952
now = datetime.now(timezone.utc)
5053
record.setdefault("created_at", now)
5154
record["updated_at"] = now

doc/guides/django.rst

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,16 @@ If :meth:`~scim2_models.Resource.model_validate` or
6767
:class:`~pydantic.ValidationError` and return a SCIM :class:`~scim2_models.Error`
6868
response.
6969

70-
Uniqueness error helper
71-
^^^^^^^^^^^^^^^^^^^^^^^
70+
SCIM exception helper
71+
^^^^^^^^^^^^^^^^^^^^^
7272

73-
``scim_uniqueness_error`` catches the ``ValueError`` raised by ``save_record`` and returns a
74-
409 with ``scimType: uniqueness`` using :class:`~scim2_models.UniquenessException`.
73+
``scim_exception_error`` converts any :class:`~scim2_models.SCIMException`
74+
(uniqueness, mutability, …) into a SCIM error response.
7575

7676
.. literalinclude:: _examples/django_example.py
7777
:language: python
78-
:start-after: # -- uniqueness-helper-start --
79-
:end-before: # -- uniqueness-helper-end --
78+
:start-after: # -- scim-exception-helper-start --
79+
:end-before: # -- scim-exception-helper-end --
8080

8181
Precondition error helper
8282
^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -121,8 +121,9 @@ For ``GET``, parse query parameters with :class:`~scim2_models.ResponseParameter
121121
SCIM resource, and serialize with :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`.
122122
For ``DELETE``, remove the record and return an empty 204 response.
123123
For ``PUT``, validate the full replacement payload with
124-
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, passing the ``original`` resource
125-
so that immutable attributes are checked for unintended modifications.
124+
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, then call
125+
:meth:`~scim2_models.Resource.replace` to verify that immutable attributes
126+
have not been modified.
126127
Convert back to native and persist, then serialize with
127128
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_RESPONSE`.
128129
For ``PATCH``, validate the payload with :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`,

doc/guides/fastapi.rst

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ validation errors, HTTP exceptions, and application errors aligned with SCIM res
7272
response.
7373
``handle_http_exception`` catches HTTP errors such as the 404 raised by the dependency and wraps
7474
them in a SCIM :class:`~scim2_models.Error`.
75-
``handle_value_error`` catches the ``ValueError`` raised by ``save_record`` and returns a 409
76-
with ``scimType: uniqueness`` using :class:`~scim2_models.UniquenessException`.
75+
``handle_scim_error`` catches any :class:`~scim2_models.SCIMException` (uniqueness, mutability, …)
76+
and returns the appropriate SCIM :class:`~scim2_models.Error` response.
7777
``handle_precondition_failed`` catches
7878
:class:`~doc.guides._examples.integrations.PreconditionFailed` errors raised by the
7979
:ref:`ETag helpers <etag-helpers>` and returns a 412.
@@ -129,10 +129,9 @@ PUT /Users/<id>
129129
^^^^^^^^^^^^^^^
130130

131131
Validate the full replacement payload with
132-
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, passing the ``original`` resource
133-
so that immutable attributes are checked for unintended modifications.
134-
Convert back to native and persist, then serialize the result with
135-
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_RESPONSE`.
132+
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, then call
133+
:meth:`~scim2_models.Resource.replace` to verify that immutable attributes
134+
have not been modified.
136135

137136
.. literalinclude:: _examples/fastapi_example.py
138137
:language: python

doc/guides/flask.rst

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ responses.
6666
If :meth:`~scim2_models.Resource.model_validate`, Flask routes the
6767
:class:`~pydantic.ValidationError` to ``handle_validation_error`` and the client receives a
6868
SCIM :class:`~scim2_models.Error` response.
69-
``handle_value_error`` catches the ``ValueError`` raised by ``save_record`` and returns a 409
70-
with ``scimType: uniqueness`` using :class:`~scim2_models.UniquenessException`.
69+
``handle_scim_error`` catches any :class:`~scim2_models.SCIMException` (uniqueness, mutability, …)
70+
and returns the appropriate SCIM :class:`~scim2_models.Error` response.
7171
``handle_precondition_failed`` catches
7272
:class:`~doc.guides._examples.integrations.PreconditionFailed` errors raised by the
7373
:ref:`ETag helpers <etag-helpers>` and returns a 412.
@@ -122,10 +122,9 @@ PUT /Users/<id>
122122
^^^^^^^^^^^^^^^
123123

124124
Validate the full replacement payload with
125-
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, passing the ``original`` resource
126-
so that immutable attributes are checked for unintended modifications.
127-
Convert back to native and persist, then serialize the result with
128-
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_RESPONSE`.
125+
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, then call
126+
:meth:`~scim2_models.Resource.replace` to verify that immutable attributes
127+
have not been modified.
129128

130129
.. literalinclude:: _examples/flask_example.py
131130
:language: python

doc/tutorial.rst

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,6 @@ fields with unexpected values will raise :class:`~pydantic.ValidationError`:
124124
... except pydantic.ValidationError:
125125
... obj = Error(...)
126126
127-
.. note::
128-
129-
With the :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST` context,
130-
:meth:`~scim2_models.BaseModel.model_validate` takes an additional
131-
:paramref:`~scim2_models.BaseModel.model_validate.original` argument that is used to compare
132-
:attr:`~scim2_models.Mutability.immutable` attributes, and raise an exception when they have mutated.
133-
134127
Attributes inclusions and exclusions
135128
====================================
136129

@@ -479,6 +472,29 @@ Client applications can use this to dynamically discover server resources by bro
479472
:language: json
480473
:caption: schema-group.json
481474
475+
Replace operations
476+
==================
477+
478+
When handling a ``PUT`` request, validate the incoming payload with the
479+
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST` context, then call
480+
:meth:`~scim2_models.Resource.replace` against the existing resource to
481+
verify that :attr:`~scim2_models.Mutability.immutable` attributes have not been
482+
modified.
483+
484+
.. doctest::
485+
486+
>>> from scim2_models import User, Context
487+
>>> from scim2_models.exceptions import MutabilityException
488+
>>> existing = User(user_name="bjensen")
489+
>>> replacement = User.model_validate(
490+
... {"userName": "bjensen"},
491+
... scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
492+
... )
493+
>>> replacement.replace(existing)
494+
495+
If an immutable attribute differs, a :class:`~scim2_models.MutabilityException`
496+
is raised.
497+
482498
Patch operations
483499
================
484500

0 commit comments

Comments
 (0)