Skip to content

Commit 72f86e3

Browse files
committed
feat: pass the ScimProvider in the validation/serialization context
1 parent 98b6ef4 commit 72f86e3

5 files changed

Lines changed: 362 additions & 4 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ 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`.
4756
- :meth:`~scim2_models.PatchOp.build_from` builds the patch turning one resource state into
4857
another. Only the attributes the wanted state names take part in the comparison, so what a peer
4958
maintains and the caller does not model survives the modification — which is what a PATCH

‎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

‎scim2_models/base.py‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
if TYPE_CHECKING:
4444
from scim2_models.messages.response_parameters import ResponseParameters
4545
from scim2_models.path import Path
46+
from scim2_models.provider import ScimProvider
47+
from scim2_models.resources.service_provider_config import ServiceProviderConfig
4648

4749

4850
def _short_attr_path(urn: str) -> str:
@@ -810,11 +812,15 @@ def _prepare_model_validate(
810812
cls,
811813
scim_ctx: Context | None = Context.DEFAULT,
812814
scim_policy: ScimPolicy | None = None,
815+
scim_provider: "ScimProvider | None" = None,
816+
scim_spc: "ServiceProviderConfig | None" = None,
813817
**kwargs: Any,
814818
) -> dict[str, Any]:
815819
context = kwargs.setdefault("context", {})
816820
context.setdefault("scim", scim_ctx)
817821
context.setdefault("scim_policy", scim_policy)
822+
context.setdefault("scim_provider", scim_provider)
823+
context.setdefault("scim_spc", scim_spc)
818824
return kwargs
819825

820826
@classmethod
@@ -823,15 +829,29 @@ def model_validate(
823829
*args: Any,
824830
scim_ctx: Context | None = Context.DEFAULT,
825831
scim_policy: ScimPolicy | None = None,
832+
scim_provider: "ScimProvider | None" = None,
833+
scim_spc: "ServiceProviderConfig | None" = None,
826834
**kwargs: Any,
827835
) -> Self:
828836
"""Validate SCIM payloads and generate model representation by using Pydantic :meth:`~pydantic.BaseModel.model_validate`.
829837
830838
:param scim_ctx: The SCIM :class:`~scim2_models.Context` in which the validation happens.
831839
:param scim_policy: The :class:`~scim2_models.ScimPolicy` the validation
832840
runs under. Defaults to the strict reading of the specification.
841+
:param scim_provider: The :class:`~scim2_models.ScimProvider` describing
842+
the service the payload belongs to. Defaults to the provider of the
843+
innermost open block, if any.
844+
:param scim_spc: The
845+
:class:`~scim2_models.ServiceProviderConfig` the peer publishes,
846+
which overrides the one *scim_provider* carries.
833847
"""
834-
validate_kwargs = cls._prepare_model_validate(scim_ctx, scim_policy, **kwargs)
848+
validate_kwargs = cls._prepare_model_validate(
849+
scim_ctx,
850+
scim_policy,
851+
scim_provider=scim_provider,
852+
scim_spc=scim_spc,
853+
**kwargs,
854+
)
835855
return super().model_validate(*args, **validate_kwargs)
836856

837857
@classmethod
@@ -840,6 +860,8 @@ def model_validate_json(
840860
*args: Any,
841861
scim_ctx: Context | None = Context.DEFAULT,
842862
scim_policy: ScimPolicy | None = None,
863+
scim_provider: "ScimProvider | None" = None,
864+
scim_spc: "ServiceProviderConfig | None" = None,
843865
**kwargs: Any,
844866
) -> Self:
845867
"""Validate SCIM JSON payloads and generate model representation by using Pydantic :meth:`~pydantic.BaseModel.model_validate_json`.
@@ -850,9 +872,19 @@ def model_validate_json(
850872
:param scim_ctx: The SCIM :class:`~scim2_models.Context` in which the validation happens.
851873
:param scim_policy: The :class:`~scim2_models.ScimPolicy` the validation
852874
runs under. Defaults to the strict reading of the specification.
875+
:param scim_provider: The :class:`~scim2_models.ScimProvider` describing
876+
the service the payload belongs to. Defaults to the provider of the
877+
innermost open block, if any.
878+
:param scim_spc: The
879+
:class:`~scim2_models.ServiceProviderConfig` the peer publishes,
880+
which overrides the one *scim_provider* carries.
853881
"""
854882
validate_kwargs = cls._prepare_model_validate(
855-
scim_ctx, scim_policy=scim_policy, **kwargs
883+
scim_ctx,
884+
scim_policy=scim_policy,
885+
scim_provider=scim_provider,
886+
scim_spc=scim_spc,
887+
**kwargs,
856888
)
857889
return super().model_validate_json(*args, **validate_kwargs)
858890

@@ -862,11 +894,15 @@ def _prepare_model_dump(
862894
attributes: list["str | Path[Any]"] | None = None,
863895
excluded_attributes: list["str | Path[Any]"] | None = None,
864896
scim_policy: ScimPolicy | None = None,
897+
scim_provider: "ScimProvider | None" = None,
898+
scim_spc: "ServiceProviderConfig | None" = None,
865899
**kwargs: Any,
866900
) -> dict[str, Any]:
867901
context = kwargs.setdefault("context", {})
868902
context.setdefault("scim", scim_ctx)
869903
context.setdefault("scim_policy", scim_policy)
904+
context.setdefault("scim_provider", scim_provider)
905+
context.setdefault("scim_spc", scim_spc)
870906

871907
if scim_ctx:
872908
kwargs.setdefault("exclude_none", True)
@@ -925,6 +961,8 @@ def model_dump(
925961
attributes: list["str | Path[Any]"] | None = None,
926962
excluded_attributes: list["str | Path[Any]"] | None = None,
927963
scim_policy: ScimPolicy | None = None,
964+
scim_provider: "ScimProvider | None" = None,
965+
scim_spc: "ServiceProviderConfig | None" = None,
928966
**kwargs: Any,
929967
) -> dict[str, Any]:
930968
"""Create a model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump`.
@@ -953,6 +991,12 @@ def model_dump(
953991
:param scim_policy: The :class:`~scim2_models.ScimPolicy` the
954992
serialization runs under. Defaults to the strict reading of the
955993
specification.
994+
:param scim_provider: The :class:`~scim2_models.ScimProvider` describing
995+
the service the payload belongs to. Defaults to the provider of the
996+
innermost open block, if any.
997+
:param scim_spc: The
998+
:class:`~scim2_models.ServiceProviderConfig` the peer publishes,
999+
which overrides the one *scim_provider* carries.
9561000
"""
9571001
attributes, excluded_attributes = self._attribute_selection(
9581002
response_parameters, attributes, excluded_attributes
@@ -962,6 +1006,8 @@ def model_dump(
9621006
attributes=attributes,
9631007
excluded_attributes=excluded_attributes,
9641008
scim_policy=scim_policy,
1009+
scim_provider=scim_provider,
1010+
scim_spc=scim_spc,
9651011
**kwargs,
9661012
)
9671013
if scim_ctx:
@@ -976,6 +1022,8 @@ def model_dump_json(
9761022
attributes: list["str | Path[Any]"] | None = None,
9771023
excluded_attributes: list["str | Path[Any]"] | None = None,
9781024
scim_policy: ScimPolicy | None = None,
1025+
scim_provider: "ScimProvider | None" = None,
1026+
scim_spc: "ServiceProviderConfig | None" = None,
9791027
**kwargs: Any,
9801028
) -> str:
9811029
"""Create a JSON model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump_json`.
@@ -1004,6 +1052,12 @@ def model_dump_json(
10041052
:param scim_policy: The :class:`~scim2_models.ScimPolicy` the
10051053
serialization runs under. Defaults to the strict reading of the
10061054
specification.
1055+
:param scim_provider: The :class:`~scim2_models.ScimProvider` describing
1056+
the service the payload belongs to. Defaults to the provider of the
1057+
innermost open block, if any.
1058+
:param scim_spc: The
1059+
:class:`~scim2_models.ServiceProviderConfig` the peer publishes,
1060+
which overrides the one *scim_provider* carries.
10071061
"""
10081062
attributes, excluded_attributes = self._attribute_selection(
10091063
response_parameters, attributes, excluded_attributes
@@ -1013,6 +1067,8 @@ def model_dump_json(
10131067
attributes=attributes,
10141068
excluded_attributes=excluded_attributes,
10151069
scim_policy=scim_policy,
1070+
scim_provider=scim_provider,
1071+
scim_spc=scim_spc,
10161072
**kwargs,
10171073
)
10181074
return super().model_dump_json(*args, **dump_kwargs)

‎scim2_models/provider.py‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import operator
44
from collections.abc import Iterable
5+
from contextvars import ContextVar
56
from functools import cached_property
67
from functools import reduce
78
from types import TracebackType
@@ -10,6 +11,9 @@
1011
from typing import TypeVar
1112
from typing import cast
1213

14+
from pydantic import SerializationInfo
15+
from pydantic import ValidationInfo
16+
1317
from .annotations import Required
1418
from .policy import ScimPolicy
1519
from .resources.resource import Extension
@@ -60,6 +64,12 @@ def _schema_key(schema: Any) -> str:
6064
}
6165

6266

67+
_AMBIENT_PROVIDERS: ContextVar[tuple["ScimProvider", ...]] = ContextVar(
68+
"scim2_models_providers", default=()
69+
)
70+
"""The providers of the blocks a call is running inside, innermost last."""
71+
72+
6373
class ScimProviderError(ValueError):
6474
"""A provider cannot describe a coherent service.
6575
@@ -224,7 +234,8 @@ def policy(self) -> ScimPolicy:
224234
return self._policy
225235

226236
def __enter__(self) -> "ScimProvider":
227-
"""Make the policy of this provider the one the block runs under."""
237+
"""Make this provider, its configuration and its policy the ones the block runs under."""
238+
_AMBIENT_PROVIDERS.set(_AMBIENT_PROVIDERS.get() + (self,))
228239
self._policy.__enter__()
229240
return self
230241

@@ -234,8 +245,9 @@ def __exit__(
234245
exc_value: BaseException | None,
235246
traceback: TracebackType | None,
236247
) -> None:
237-
"""Restore the policy the block interrupted."""
248+
"""Restore the provider and the policy the block interrupted."""
238249
self._policy.__exit__(exc_type, exc_value, traceback)
250+
_AMBIENT_PROVIDERS.set(_AMBIENT_PROVIDERS.get()[:-1])
239251

240252
@cached_property
241253
def schemas(self) -> tuple[Schema, ...]:
@@ -343,3 +355,35 @@ def from_discovery(
343355
config=config,
344356
policy=policy,
345357
)
358+
359+
360+
def _ambient_provider() -> "ScimProvider | None":
361+
"""Return the provider of the innermost open block, if any."""
362+
providers = _AMBIENT_PROVIDERS.get()
363+
return providers[-1] if providers else None
364+
365+
366+
def _provider(info: ValidationInfo | SerializationInfo) -> "ScimProvider | None":
367+
"""Return the provider a validation or a serialization runs under.
368+
369+
Passes that no call of ours started carry no context and fall back on the
370+
provider of the innermost open block.
371+
"""
372+
context = getattr(info, "context", None) or {}
373+
return context.get("scim_provider") or _ambient_provider()
374+
375+
376+
def _spc(info: ValidationInfo | SerializationInfo) -> ServiceProviderConfig | None:
377+
"""Return the configuration the peer of a pass publishes, if it is known.
378+
379+
A configuration named at the call site wins over the one the provider
380+
carries, so a single provider serves peers that declare different
381+
capabilities.
382+
"""
383+
context = getattr(info, "context", None) or {}
384+
spc: ServiceProviderConfig | None = context.get("scim_spc")
385+
if spc:
386+
return spc
387+
388+
provider = _provider(info)
389+
return provider.config if provider else None

0 commit comments

Comments
 (0)