Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ Changelog
[0.6.8] - Unreleased
--------------------

Added
^^^^^
- :class:`~scim2_models.MutabilityException` handler in framework integration examples (FastAPI, Flask, Django).

Deprecated
^^^^^^^^^^
- 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.

Fixed
^^^^^
- 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.
Expand Down
30 changes: 16 additions & 14 deletions doc/guides/_examples/django_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
from scim2_models import PatchOp
from scim2_models import ResourceType
from scim2_models import ResponseParameters
from scim2_models import SCIMException
from scim2_models import Schema
from scim2_models import SearchRequest
from scim2_models import UniquenessException
from scim2_models import User

from .integrations import check_etag
Expand Down Expand Up @@ -81,12 +81,12 @@ def scim_validation_error(error):
# -- validation-helper-end --


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


# -- precondition-helper-start --
Expand Down Expand Up @@ -152,17 +152,19 @@ def put(self, request, app_record):
replacement = User.model_validate(
json.loads(request.body),
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
original=existing_user,
)
replacement.replace(existing_user)
except ValidationError as error:
return scim_validation_error(error)
except SCIMException as error:
return scim_exception_error(error)

replacement.id = existing_user.id
updated_record = from_scim_user(replacement)
try:
save_record(updated_record)
except ValueError as error:
return scim_uniqueness_error(error)
except SCIMException as error:
return scim_exception_error(error)

response_user = to_scim_user(updated_record, resource_location(request, updated_record))
resp = scim_response(
Expand Down Expand Up @@ -192,8 +194,8 @@ def patch(self, request, app_record):
updated_record = from_scim_user(scim_user)
try:
save_record(updated_record)
except ValueError as error:
return scim_uniqueness_error(error)
except SCIMException as error:
return scim_exception_error(error)

resp = scim_response(
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE)
Expand Down Expand Up @@ -242,8 +244,8 @@ def post(self, request):
app_record = from_scim_user(request_user)
try:
save_record(app_record)
except ValueError as error:
return scim_uniqueness_error(error)
except SCIMException as error:
return scim_exception_error(error)

response_user = to_scim_user(app_record, resource_location(request, app_record))
resp = scim_response(
Expand Down
14 changes: 7 additions & 7 deletions doc/guides/_examples/fastapi_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
from scim2_models import PatchOp
from scim2_models import ResourceType
from scim2_models import ResponseParameters
from scim2_models import SCIMException
from scim2_models import Schema
from scim2_models import SearchRequest
from scim2_models import UniquenessException
from scim2_models import User

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


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


@app.exception_handler(PreconditionFailed)
Expand Down Expand Up @@ -151,8 +151,8 @@ async def replace_user(request: Request, app_record: dict = Depends(resolve_user
replacement = User.model_validate(
await request.json(),
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
original=existing_user,
)
replacement.replace(existing_user)

replacement.id = existing_user.id
updated_record = from_scim_user(replacement)
Expand Down
14 changes: 7 additions & 7 deletions doc/guides/_examples/flask_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
from scim2_models import PatchOp
from scim2_models import ResourceType
from scim2_models import ResponseParameters
from scim2_models import SCIMException
from scim2_models import Schema
from scim2_models import SearchRequest
from scim2_models import UniquenessException
from scim2_models import User

from .integrations import check_etag
Expand Down Expand Up @@ -87,11 +87,11 @@ def handle_not_found(error):
return scim_error.model_dump_json(), HTTPStatus.NOT_FOUND


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


@bp.errorhandler(PreconditionFailed)
Expand Down Expand Up @@ -156,8 +156,8 @@ def replace_user(app_record):
replacement = User.model_validate(
request.get_json(),
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
original=existing_user,
)
replacement.replace(existing_user)

replacement.id = existing_user.id
updated_record = from_scim_user(replacement)
Expand Down
7 changes: 5 additions & 2 deletions doc/guides/_examples/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from scim2_models import ResourceType
from scim2_models import ServiceProviderConfig
from scim2_models import Sort
from scim2_models import UniquenessException
from scim2_models import User

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


def save_record(record):
"""Persist *record*, raising ValueError if its userName is already taken."""
"""Persist *record*, raising UniquenessException if its userName is already taken."""
if not record.get("id"):
record["id"] = str(uuid4())
for existing in records.values():
if existing["id"] != record["id"] and existing["user_name"] == record["user_name"]:
raise ValueError(f"userName {record['user_name']!r} is already taken")
raise UniquenessException(
detail=f"userName {record['user_name']!r} is already taken"
)
now = datetime.now(timezone.utc)
record.setdefault("created_at", now)
record["updated_at"] = now
Expand Down
17 changes: 9 additions & 8 deletions doc/guides/django.rst
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,16 @@ If :meth:`~scim2_models.Resource.model_validate` or
:class:`~pydantic.ValidationError` and return a SCIM :class:`~scim2_models.Error`
response.

Uniqueness error helper
^^^^^^^^^^^^^^^^^^^^^^^
SCIM exception helper
^^^^^^^^^^^^^^^^^^^^^

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

.. literalinclude:: _examples/django_example.py
:language: python
:start-after: # -- uniqueness-helper-start --
:end-before: # -- uniqueness-helper-end --
:start-after: # -- scim-exception-helper-start --
:end-before: # -- scim-exception-helper-end --

Precondition error helper
^^^^^^^^^^^^^^^^^^^^^^^^^
Expand Down Expand Up @@ -121,8 +121,9 @@ For ``GET``, parse query parameters with :class:`~scim2_models.ResponseParameter
SCIM resource, and serialize with :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`.
For ``DELETE``, remove the record and return an empty 204 response.
For ``PUT``, validate the full replacement payload with
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, passing the ``original`` resource
so that immutable attributes are checked for unintended modifications.
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, then call
:meth:`~scim2_models.Resource.replace` to verify that immutable attributes
have not been modified.
Convert back to native and persist, then serialize with
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_RESPONSE`.
For ``PATCH``, validate the payload with :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`,
Expand Down
11 changes: 5 additions & 6 deletions doc/guides/fastapi.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ validation errors, HTTP exceptions, and application errors aligned with SCIM res
response.
``handle_http_exception`` catches HTTP errors such as the 404 raised by the dependency and wraps
them in a SCIM :class:`~scim2_models.Error`.
``handle_value_error`` catches the ``ValueError`` raised by ``save_record`` and returns a 409
with ``scimType: uniqueness`` using :class:`~scim2_models.UniquenessException`.
``handle_scim_error`` catches any :class:`~scim2_models.SCIMException` (uniqueness, mutability, …)
and returns the appropriate SCIM :class:`~scim2_models.Error` response.
``handle_precondition_failed`` catches
:class:`~doc.guides._examples.integrations.PreconditionFailed` errors raised by the
:ref:`ETag helpers <etag-helpers>` and returns a 412.
Expand Down Expand Up @@ -129,10 +129,9 @@ PUT /Users/<id>
^^^^^^^^^^^^^^^

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

.. literalinclude:: _examples/fastapi_example.py
:language: python
Expand Down
11 changes: 5 additions & 6 deletions doc/guides/flask.rst
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ responses.
If :meth:`~scim2_models.Resource.model_validate`, Flask routes the
:class:`~pydantic.ValidationError` to ``handle_validation_error`` and the client receives a
SCIM :class:`~scim2_models.Error` response.
``handle_value_error`` catches the ``ValueError`` raised by ``save_record`` and returns a 409
with ``scimType: uniqueness`` using :class:`~scim2_models.UniquenessException`.
``handle_scim_error`` catches any :class:`~scim2_models.SCIMException` (uniqueness, mutability, …)
and returns the appropriate SCIM :class:`~scim2_models.Error` response.
``handle_precondition_failed`` catches
:class:`~doc.guides._examples.integrations.PreconditionFailed` errors raised by the
:ref:`ETag helpers <etag-helpers>` and returns a 412.
Expand Down Expand Up @@ -122,10 +122,9 @@ PUT /Users/<id>
^^^^^^^^^^^^^^^

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

.. literalinclude:: _examples/flask_example.py
:language: python
Expand Down
30 changes: 23 additions & 7 deletions doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,6 @@ fields with unexpected values will raise :class:`~pydantic.ValidationError`:
... except pydantic.ValidationError:
... obj = Error(...)

.. note::

With the :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST` context,
:meth:`~scim2_models.BaseModel.model_validate` takes an additional
:paramref:`~scim2_models.BaseModel.model_validate.original` argument that is used to compare
:attr:`~scim2_models.Mutability.immutable` attributes, and raise an exception when they have mutated.

Attributes inclusions and exclusions
====================================

Expand Down Expand Up @@ -479,6 +472,29 @@ Client applications can use this to dynamically discover server resources by bro
:language: json
:caption: schema-group.json

Replace operations
==================

When handling a ``PUT`` request, validate the incoming payload with the
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST` context, then call
:meth:`~scim2_models.Resource.replace` against the existing resource to
verify that :attr:`~scim2_models.Mutability.immutable` attributes have not been
modified.

.. doctest::

>>> from scim2_models import User, Context
>>> from scim2_models.exceptions import MutabilityException
>>> existing = User(user_name="bjensen")
>>> replacement = User.model_validate(
... {"userName": "bjensen"},
... scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
... )
>>> replacement.replace(existing)

If an immutable attribute differs, a :class:`~scim2_models.MutabilityException`
is raised.

Patch operations
================

Expand Down
Loading
Loading