Skip to content

Commit 3cc7546

Browse files
committed
Merge branch 'main' into cursor-based-pagination
# Conflicts: # doc/changelog.rst
2 parents 3067d54 + 72f86e3 commit 3cc7546

39 files changed

Lines changed: 3402 additions & 355 deletions

‎doc/changelog.rst‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,42 @@ Added
4444
:class:`~scim2_models.ResponseParameters` a client sent, instead of its ``attributes`` and
4545
``excludedAttributes`` spelled out one by one. A :class:`~scim2_models.SearchRequest` is one,
4646
so a server answering ``POST /.search`` passes the request it received. :issue:`141`
47+
- :meth:`~scim2_models.BaseModel.model_validate`,
48+
:meth:`~scim2_models.BaseModel.model_validate_json`,
49+
:meth:`~scim2_models.BaseModel.model_dump` and
50+
:meth:`~scim2_models.BaseModel.model_dump_json` take a ``scim_provider`` and a ``scim_spc``: the
51+
:class:`~scim2_models.ScimProvider` describing the service a payload belongs to, and the
52+
:class:`~scim2_models.ServiceProviderConfig` its peer publishes. A ``with`` block opened on a
53+
provider lends both, as it already lends its policy, and ``scim_spc`` wins over the
54+
configuration the provider carries. Rules the specification makes conditional on a declared
55+
capability read them. See :doc:`how-to/describe-a-scim-service`.
56+
- :meth:`~scim2_models.PatchOp.build_from` builds the patch turning one resource state into
57+
another. Only the attributes the wanted state names take part in the comparison, so what a peer
58+
maintains and the caller does not model survives the modification — which is what a PATCH
59+
offers over a PUT. See :doc:`how-to/build-a-patch`. :issue:`104`
60+
- Bulk messages are validated, in the new :attr:`~scim2_models.Context.BULK_REQUEST` and
61+
:attr:`~scim2_models.Context.BULK_RESPONSE` contexts. Each operation's
62+
:attr:`~scim2_models.BulkOperation.data` is checked as the single request it stands for: a
63+
creation for a POST, a patch for a PATCH. See :ref:`helpers-bulk`. :pr:`149`
4764
- lark is a new dependency.
4865
- Support for :rfc:`RFC9865 <9865>`
4966

5067
Changed
5168
^^^^^^^
69+
- The bulk models take the resource type their operations carry, as in ``BulkRequest[User]`` or
70+
``BulkRequest[User | Group]``, and raise a :class:`TypeError` when used bare. A payload the type
71+
parameter does not cover is now refused, and a bulk response no longer dumps ``path``.
72+
- :class:`~scim2_models.ListResponse` raises a :class:`TypeError` when used without the resource
73+
type its entries carry, as :class:`~scim2_models.PatchOp` and the bulk models do. A bare
74+
``ListResponse`` used to answer a pydantic error naming ``Resource``, and whether it did depended
75+
on what the calling module had imported.
76+
- A message type parameter must name resource types. ``ListResponse[str]`` used to build a class
77+
that read anything as its entries.
78+
- ``ListResponse[Resource]``, ``PatchOp[Resource]`` and their bulk counterparts stay writable where
79+
a type is expected, which is what an annotation covering any resource type needs, and raise a
80+
:class:`TypeError` when they read or build a payload. ``Resource`` declares no attribute, so a
81+
payload read against it fails on the first one it carries. ``PatchOp[Resource]`` used to be
82+
refused as a type, and ``ListResponse[Resource]`` used to read payloads.
5283
- :attr:`SearchRequest.filter <scim2_models.SearchRequest.filter>` is a
5384
:class:`~scim2_models.ScimFilter` instead of a :class:`str`, so a malformed filter is rejected
5485
at validation time.
@@ -63,6 +94,9 @@ Changed
6394
when applied. It used to remove the entries equal to that ``value``, and to report no change
6495
when the ``value`` was a list or described an entry only in part. Set
6596
:attr:`~scim2_models.ScimPolicy.remove_value_as_filter` to keep reading it.
97+
- A PATCH reaching an extension attribute takes the extended resource type, as in
98+
``PatchOp[User[EnterpriseUser]]``. ``PatchOp[User]`` used to carry such an operation to the
99+
endpoint, and now refuses a path its type parameter leaves out.
66100
- :meth:`SCIMException.from_error <scim2_models.SCIMException.from_error>` reconstructs
67101
:class:`~scim2_models.InvalidCursorException`, :class:`~scim2_models.ExpiredCursorException` and
68102
:class:`~scim2_models.InvalidCountException` from an :class:`~scim2_models.Error` carrying the
@@ -96,12 +130,21 @@ Deprecated
96130

97131
Fixed
98132
^^^^^
133+
- A bulk model indexed with something other than a resource type names itself in the error. The
134+
rules of the :class:`~scim2_models.PatchOp` its operations carry used to answer for it, so
135+
``BulkRequest[str]`` told the caller to write ``PatchOp[User]``.
136+
- A subclass of a parameterized message, such as ``class Users(ListResponse[User])``, reads its
137+
payloads with the type parameter it inherits. It used to raise an :exc:`IndexError`, a subclass
138+
carrying no parameter of its own.
99139
- A PATCH operation targeting an attribute of an extension answers for the constraints that
100140
extension declares, where it used to look them up on the resource and find none. A refused
101141
operation no longer leaves the extension instantiated on the resource.
102142
- A PATCH operation carrying no ``path`` accepts a resource as its ``value``, and checks the
103143
attributes it names against the model. They used to go through unexamined, so a client naming
104144
an attribute it had misspelled was answered success.
145+
- A PATCH operation whose ``path`` names an attribute the resource schema does not declare is
146+
refused with ``invalidPath``. It used to pass, so a client that misspelled an attribute was
147+
answered success without anything being written. :issue:`164`
105148
- A refused PATCH ``add`` on a multi-valued attribute leaves the attribute as it was. The entry
106149
used to be appended before being validated, and outlived the failure.
107150
- A PATCH operation carrying no ``path`` marks the attributes it assigns as set, so

‎doc/explanation/patch.rst‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,5 +168,17 @@ model does not declare. Table 9 lists ``invalidFilter`` as applying to a "PATCH
168168
so it answers what goes wrong between the brackets. That covers an unknown sub-attribute, a
169169
comparison the attribute cannot take, and a selection over an attribute holding a single value.
170170

171+
Building a patch rather than applying one
172+
-----------------------------------------
173+
174+
An application holding both the state a peer has and the state it should have does not have to
175+
spell the operations out. :meth:`~scim2_models.PatchOp.build_from` compares the two states and
176+
builds them, restricted to the attributes the wanted state names, so what the peer maintains and
177+
the application does not model is left alone.
178+
179+
What that builder can express follows from this page. A multi-valued attribute is replaced as a
180+
whole, since :rfc:`RFC7643 §2.4 <7643#section-2.4>` gives its entries no identity to match one
181+
state against the other. See :doc:`../how-to/build-a-patch`.
182+
171183
To inspect or change a resource directly with the same path syntax, outside a PATCH request, use
172184
:doc:`../how-to/access-resource-values`.

‎doc/explanation/policies.rst‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,13 @@ stand in for one.
4646
What a policy leaves alone
4747
--------------------------
4848

49-
**PATCH paths stay strict.** An operation whose ``path`` names an attribute no model declares
50-
raises :class:`~scim2_models.PathNotFoundException`, whatever the policy says. Path resolution and
51-
unknown attributes are two separate mechanisms, and making them uniform would take a third. The
52-
default that would come out of it is the wrong one: a server would answer 200 to a modification it
53-
never applied, where :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` asks for an error. Inside the body
54-
of a resource the trade is different, since dropping one unknown attribute still lands everything
55-
the peer and the model both knew.
49+
**PATCH paths stay strict.** An operation whose ``path`` names an attribute no model declares is
50+
refused with ``invalidPath``, whatever the policy says. Path resolution and unknown attributes are
51+
two separate mechanisms, and making them uniform would take a third. The default that would come
52+
out of it is the wrong one: a server would answer 200 to a modification it never applied, where
53+
:rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` asks for an error. Inside the body of a resource the
54+
trade is different, since dropping one unknown attribute still lands everything the peer and the
55+
model both knew.
5656

5757
**Building a model in Python stays strict.** ``User(bogus=1)`` and ``user.bogus = 1`` raise under
5858
every policy. Pydantic only offers a hook for extra keys during validation, so a keyword argument

‎doc/how-to/build-a-patch.rst‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
Build a patch from two resource states
2+
======================================
3+
4+
Use this guide when an application holds the state a peer is believed to have and the state it
5+
should have, and must send the modification. :meth:`~scim2_models.PatchOp.build_from` compares the
6+
two and returns the :class:`~scim2_models.PatchOp` that closes the gap.
7+
8+
Build the patch
9+
---------------
10+
11+
Pass the state the peer holds first, then the state it should hold:
12+
13+
.. doctest::
14+
15+
>>> from scim2_models import Name, PatchOp, User
16+
>>> distant = User(user_name="bjensen", name=Name(given_name="Barbara", family_name="Jensen"))
17+
>>> wanted = User(user_name="bjensen", name=Name(given_name="Babs", family_name="Jensen"))
18+
>>> patch = PatchOp.build_from(distant, wanted)
19+
>>> patch.model_dump()["Operations"]
20+
[{'op': 'replace', 'path': 'name.givenName', 'value': 'Babs'}]
21+
22+
A complex attribute is compared sub-attribute by sub-attribute, and each one gets its own path.
23+
Targeting ``name`` as a whole would replace it entirely and drop what the operation does not
24+
carry.
25+
26+
Leave alone what the wanted state does not name
27+
-----------------------------------------------
28+
29+
Only the attributes the wanted state names take part in the comparison. This is what separates a
30+
patch from the :meth:`~scim2_models.Resource.replace` it stands for: an attribute the peer
31+
maintains and the application does not model survives the modification.
32+
33+
.. doctest::
34+
35+
>>> distant = User(user_name="bjensen", nick_name="Barb", title="CEO")
36+
>>> wanted = User(user_name="bjensen", nick_name="Babs")
37+
>>> patch = PatchOp.build_from(distant, wanted)
38+
>>> patch.model_dump()["Operations"]
39+
[{'op': 'replace', 'path': 'nickName', 'value': 'Babs'}]
40+
41+
Naming an attribute with no value says the opposite. ``title=None`` reads as "clear the title",
42+
where an unnamed ``title`` reads as "leave it alone":
43+
44+
.. doctest::
45+
46+
>>> wanted = User(user_name="bjensen", title=None)
47+
>>> patch = PatchOp.build_from(distant, wanted)
48+
>>> patch.model_dump()["Operations"]
49+
[{'op': 'remove', 'path': 'title'}]
50+
51+
The same rule reaches sub-attributes, and an extension is named by its schema URN:
52+
53+
.. doctest::
54+
55+
>>> from scim2_models import EnterpriseUser
56+
>>> distant = User[EnterpriseUser](user_name="bjensen")
57+
>>> distant[EnterpriseUser] = EnterpriseUser(department="Tour")
58+
>>> wanted = User[EnterpriseUser](user_name="bjensen")
59+
>>> wanted[EnterpriseUser] = EnterpriseUser(department="Chess")
60+
>>> patch = PatchOp.build_from(distant, wanted)
61+
>>> str(patch.operations[0].path)
62+
'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department'
63+
64+
Send nothing when nothing changed
65+
---------------------------------
66+
67+
Two states that agree have no patch to describe: an ``Operations`` array must hold at least one
68+
operation, per :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. :meth:`~scim2_models.PatchOp.build_from`
69+
returns :data:`None`, so an application tests it before sending a request:
70+
71+
.. doctest::
72+
73+
>>> PatchOp.build_from(wanted, wanted) is None
74+
True
75+
76+
Know how collections are compared
77+
---------------------------------
78+
79+
A multi-valued attribute is replaced as a whole. Only the sub-attributes the wanted entries name
80+
decide whether it changed, so the sub-attributes the peer alone maintains do not read as a
81+
difference:
82+
83+
.. doctest::
84+
85+
>>> from scim2_models import Email
86+
>>> distant = User(emails=[Email(value="barb@example.com", type="work", primary=True)])
87+
>>> wanted = User(emails=[Email(value="barb@example.com")])
88+
>>> PatchOp.build_from(distant, wanted) is None
89+
True
90+
91+
When the collection does change, the whole of it is replaced and the peer's own sub-attributes go
92+
with it:
93+
94+
.. doctest::
95+
96+
>>> wanted = User(emails=[Email(value="babs@example.com")])
97+
>>> patch = PatchOp.build_from(distant, wanted)
98+
>>> patch.model_dump()["Operations"]
99+
[{'op': 'replace', 'path': 'emails', 'value': [{'value': 'babs@example.com'}]}]
100+
101+
The builder cannot do better: :rfc:`RFC7643 §2.4 <7643#section-2.4>` gives the entries of a
102+
multi-valued attribute no identity, so nothing distinguishes an entry that changed from an entry
103+
that was removed and another that was added. An application that knows how to identify its own
104+
entries writes those operations itself, targeting a sub-attribute through a filter such as
105+
``emails[type eq "work"].value``.
106+
107+
Read what the patch never carries
108+
---------------------------------
109+
110+
A ``readOnly`` attribute is left out however much the two states differ:
111+
:rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` forbids a client to modify one, and naming it would
112+
make the patch invalid. That covers :attr:`~scim2_models.Resource.id`,
113+
:attr:`~scim2_models.Resource.meta` and :attr:`~scim2_models.User.groups`, along with the
114+
``readOnly`` sub-attributes of a complex attribute.
115+
116+
An ``immutable`` attribute that holds no value yet is added, which
117+
:rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` allows. One that already holds a value cannot be
118+
modified, and :meth:`~scim2_models.PatchOp.build_from` raises a
119+
:class:`~scim2_models.MutabilityException` rather than building a request the peer must refuse.
120+
121+
An attribute a server never returns, such as :attr:`~scim2_models.User.password`, reads as unset on
122+
the side of the peer. Every patch built from a state naming it carries it again.

‎doc/how-to/describe-a-scim-service.rst‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,42 @@ within which bounds (:rfc:`RFC7644 §4 <7644#section-4>`). Pass it alongside the
7272
>>> provider.config.filter.max_results
7373
200
7474

75+
Read the description during a validation
76+
----------------------------------------
77+
78+
A rule the specification makes conditional on a capability needs what the service declares, not
79+
only the payload. Name the provider at the call, and every validator the pass reaches sees it,
80+
down to a nested attribute:
81+
82+
.. doctest::
83+
84+
>>> payload = {"schemas": [str(User.__schema__)], "userName": "bjensen"}
85+
>>> user = User.model_validate(payload, scim_provider=provider)
86+
>>> user.user_name
87+
'bjensen'
88+
89+
A server states it once per request instead, by opening a block on the provider. The block lends
90+
its policy the same way:
91+
92+
.. doctest::
93+
94+
>>> with provider:
95+
... user = User.model_validate(payload)
96+
97+
A client holds the configuration of a peer as soon as it has queried ``/ServiceProviderConfig``,
98+
before it builds any provider. Pass it alone:
99+
100+
.. doctest::
101+
102+
>>> user = User.model_validate(payload, scim_spc=config)
103+
104+
``scim_spc`` also wins over the configuration a provider carries, so one provider answers peers
105+
that declare different capabilities. Both parameters are taken by
106+
:meth:`~scim2_models.BaseModel.model_validate`,
107+
:meth:`~scim2_models.BaseModel.model_validate_json`,
108+
:meth:`~scim2_models.BaseModel.model_dump` and
109+
:meth:`~scim2_models.BaseModel.model_dump_json`.
110+
75111
Serve two variants of one resource
76112
----------------------------------
77113

‎doc/how-to/index.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ ends where an application resumes its own work, and assumes the :doc:`../overvie
88
:maxdepth: 1
99

1010
access-resource-values
11+
build-a-patch
1112
build-filters
1213
define-custom-models
1314
describe-a-scim-service

‎doc/how-to/validate-and-serialize.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ in a request context, responses are validated and serialized in the matching res
3636
* - ``POST /Users/.search``
3737
- :attr:`~scim2_models.Context.SEARCH_REQUEST`
3838
- :attr:`~scim2_models.Context.SEARCH_RESPONSE`
39+
* - ``POST /Bulk``
40+
- :attr:`~scim2_models.Context.BULK_REQUEST`
41+
- :attr:`~scim2_models.Context.BULK_RESPONSE`
3942

4043
:attr:`~scim2_models.Context.DEFAULT` applies neither set of rules, and suits a resource held in
4144
application state rather than exchanged over HTTP.

‎doc/integrations/_examples/django_example.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from django.views.decorators.csrf import csrf_exempt
1212
from pydantic import ValidationError
1313

14+
from scim2_models import BulkRequest
1415
from scim2_models import Context
1516
from scim2_models import Error
1617
from scim2_models import ListResponse
@@ -24,6 +25,7 @@
2425
from scim2_models import User
2526

2627
from .integrations import delete_record
28+
from .integrations import execute_bulk
2729
from .integrations import from_scim_user
2830
from .integrations import get_record
2931
from .integrations import get_resource_type
@@ -397,13 +399,39 @@ def post(self, request):
397399
# -- search-root-end --
398400

399401

402+
# -- bulk-start --
403+
class BulkView(SCIMView):
404+
"""Apply a bulk job and answer one result per operation."""
405+
406+
def post(self, request):
407+
try:
408+
bulk_request = BulkRequest[User].model_validate_json(
409+
request.body, scim_ctx=Context.BULK_REQUEST
410+
)
411+
except ValidationError as error:
412+
return scim_validation_error(error)
413+
414+
try:
415+
bulk_response = execute_bulk(
416+
bulk_request, lambda record: resource_location(request, record)
417+
)
418+
except SCIMException as error:
419+
return scim_exception_error(error)
420+
421+
return SCIMJsonResponse(bulk_response.model_dump(scim_ctx=Context.BULK_RESPONSE))
422+
423+
424+
# -- bulk-end --
425+
426+
400427
# -- urls-start --
401428
# ``/Users/.search`` comes first: the ``user`` converter would otherwise read
402429
# ``.search`` as a resource id.
403430
urlpatterns = [
404431
path("scim/v2/Users", UsersView.as_view(), name="scim_users"),
405432
path("scim/v2/Users/.search", UsersSearchView.as_view(), name="scim_users_search"),
406433
path("scim/v2/.search", RootSearchView.as_view(), name="scim_root_search"),
434+
path("scim/v2/Bulk", BulkView.as_view(), name="scim_bulk"),
407435
path("scim/v2/Users/<user:app_record>", UserView.as_view(), name="scim_user"),
408436
]
409437
# -- urls-end --

‎doc/integrations/_examples/fastapi_example.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from fastapi.responses import JSONResponse
1414
from pydantic import ValidationError
1515

16+
from scim2_models import BulkRequest
17+
from scim2_models import BulkRequestContext
1618
from scim2_models import Context
1719
from scim2_models import CreationRequestContext
1820
from scim2_models import Error
@@ -32,6 +34,7 @@
3234
from scim2_models import User
3335

3436
from .integrations import delete_record
37+
from .integrations import execute_bulk
3538
from .integrations import from_scim_user
3639
from .integrations import get_record
3740
from .integrations import get_resource_type
@@ -327,6 +330,19 @@ async def create_user(
327330
# -- collection-end --
328331

329332

333+
# -- bulk-start --
334+
@router.post("/Bulk")
335+
async def bulk(
336+
request: Request, bulk_request: BulkRequestContext[BulkRequest[User]]
337+
):
338+
"""Apply a bulk job and answer one result per operation."""
339+
bulk_response = execute_bulk(
340+
bulk_request, lambda record: resource_location(request, record)
341+
)
342+
return SCIMResponse(bulk_response.model_dump(scim_ctx=Context.BULK_RESPONSE))
343+
# -- bulk-end --
344+
345+
330346
# -- discovery-start --
331347
# -- schemas-start --
332348
@router.get("/Schemas")

0 commit comments

Comments
 (0)