Skip to content

Commit db75212

Browse files
authored
feat: add Resource.replace() for PUT immutability verification (#138)
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 db75212

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)