|
3 | 3 | from collections.abc import Collection
|
4 | 4 | from dataclasses import dataclass
|
5 | 5 | from typing import Optional
|
| 6 | +from typing import TypeVar |
6 | 7 | from typing import Union
|
7 | 8 |
|
8 | 9 | from pydantic import ValidationError
|
|
27 | 28 | from scim2_client.errors import UnexpectedContentType
|
28 | 29 | from scim2_client.errors import UnexpectedStatusCode
|
29 | 30 |
|
| 31 | +ResourceT = TypeVar("ResourceT", bound=Resource) |
| 32 | + |
30 | 33 | BASE_HEADERS = {
|
31 | 34 | "Accept": "application/scim+json",
|
32 | 35 | "Content-Type": "application/scim+json",
|
@@ -151,6 +154,26 @@ class SCIMClient:
|
151 | 154 | :rfc:`RFC7644 §3.12 <7644#section-3.12>`.
|
152 | 155 | """
|
153 | 156 |
|
| 157 | + PATCH_RESPONSE_STATUS_CODES: list[int] = [ |
| 158 | + 200, |
| 159 | + 204, |
| 160 | + 307, |
| 161 | + 308, |
| 162 | + 400, |
| 163 | + 401, |
| 164 | + 403, |
| 165 | + 404, |
| 166 | + 409, |
| 167 | + 412, |
| 168 | + 500, |
| 169 | + 501, |
| 170 | + ] |
| 171 | + """Resource patching HTTP codes. |
| 172 | +
|
| 173 | + As defined at :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` and |
| 174 | + :rfc:`RFC7644 §3.12 <7644#section-3.12>`. |
| 175 | + """ |
| 176 | + |
154 | 177 | def __init__(
|
155 | 178 | self,
|
156 | 179 | resource_models: Optional[Collection[type[Resource]]] = None,
|
@@ -299,11 +322,15 @@ def check_response(
|
299 | 322 | if not expected_types:
|
300 | 323 | return response_payload
|
301 | 324 |
|
| 325 | + # For no-content responses, return None directly |
| 326 | + if response_payload is None: |
| 327 | + return None |
| 328 | + |
302 | 329 | actual_type = Resource.get_by_payload(
|
303 | 330 | expected_types, response_payload, with_extensions=False
|
304 | 331 | )
|
305 | 332 |
|
306 |
| - if response_payload and not actual_type: |
| 333 | + if not actual_type: |
307 | 334 | expected = ", ".join([type_.__name__ for type_ in expected_types])
|
308 | 335 | try:
|
309 | 336 | schema = ", ".join(response_payload["schemas"])
|
@@ -534,9 +561,76 @@ def prepare_replace_request(
|
534 | 561 |
|
535 | 562 | return req
|
536 | 563 |
|
| 564 | + def prepare_patch_request( |
| 565 | + self, |
| 566 | + resource_model: type[ResourceT], |
| 567 | + id: str, |
| 568 | + patch_op: Union[PatchOp[ResourceT], dict], |
| 569 | + check_request_payload: Optional[bool] = None, |
| 570 | + expected_status_codes: Optional[list[int]] = None, |
| 571 | + raise_scim_errors: Optional[bool] = None, |
| 572 | + **kwargs, |
| 573 | + ) -> RequestPayload: |
| 574 | + """Prepare a PATCH request payload. |
| 575 | +
|
| 576 | + :param resource_model: The resource type to modify (e.g., User, Group). |
| 577 | + :param id: The resource ID. |
| 578 | + :param patch_op: A PatchOp instance parameterized with the same resource type as resource_model |
| 579 | + (e.g., PatchOp[User] when resource_model is User), or a dict representation. |
| 580 | + :param check_request_payload: If :data:`False`, :code:`patch_op` is expected to be a dict |
| 581 | + that will be passed as-is in the request. This value can be |
| 582 | + overwritten in methods. |
| 583 | + :param expected_status_codes: List of HTTP status codes expected for this request. |
| 584 | + :param raise_scim_errors: If :data:`True` and the server returned an |
| 585 | + :class:`~scim2_models.Error` object during a request, a |
| 586 | + :class:`~scim2_client.SCIMResponseErrorObject` exception will be raised. |
| 587 | + :param kwargs: Additional request parameters. |
| 588 | + :return: The prepared request payload. |
| 589 | + """ |
| 590 | + req = RequestPayload( |
| 591 | + expected_status_codes=expected_status_codes, |
| 592 | + request_kwargs=kwargs, |
| 593 | + ) |
| 594 | + |
| 595 | + if check_request_payload is None: |
| 596 | + check_request_payload = self.check_request_payload |
| 597 | + |
| 598 | + self.check_resource_model(resource_model) |
| 599 | + |
| 600 | + if not check_request_payload: |
| 601 | + req.payload = patch_op |
| 602 | + req.url = req.request_kwargs.pop( |
| 603 | + "url", f"{self.resource_endpoint(resource_model)}/{id}" |
| 604 | + ) |
| 605 | + |
| 606 | + else: |
| 607 | + if isinstance(patch_op, dict): |
| 608 | + req.payload = patch_op |
| 609 | + else: |
| 610 | + try: |
| 611 | + req.payload = patch_op.model_dump( |
| 612 | + scim_ctx=Context.RESOURCE_PATCH_REQUEST |
| 613 | + ) |
| 614 | + except ValidationError as exc: |
| 615 | + scim_validation_exc = RequestPayloadValidationError(source=patch_op) |
| 616 | + if sys.version_info >= (3, 11): # pragma: no cover |
| 617 | + scim_validation_exc.add_note(str(exc)) |
| 618 | + raise scim_validation_exc from exc |
| 619 | + |
| 620 | + req.url = req.request_kwargs.pop( |
| 621 | + "url", f"{self.resource_endpoint(resource_model)}/{id}" |
| 622 | + ) |
| 623 | + |
| 624 | + req.expected_types = [resource_model] |
| 625 | + return req |
| 626 | + |
537 | 627 | def modify(
|
538 |
| - self, resource: Union[AnyResource, dict], op: PatchOp, **kwargs |
539 |
| - ) -> Optional[Union[AnyResource, dict]]: |
| 628 | + self, |
| 629 | + resource_model: type[ResourceT], |
| 630 | + id: str, |
| 631 | + patch_op: Union[PatchOp[ResourceT], dict], |
| 632 | + **kwargs, |
| 633 | + ) -> Optional[Union[ResourceT, Error, dict]]: |
540 | 634 | raise NotImplementedError()
|
541 | 635 |
|
542 | 636 | def build_resource_models(
|
@@ -820,6 +914,62 @@ def replace(
|
820 | 914 | """
|
821 | 915 | raise NotImplementedError()
|
822 | 916 |
|
| 917 | + def modify( |
| 918 | + self, |
| 919 | + resource_model: type[ResourceT], |
| 920 | + id: str, |
| 921 | + patch_op: Union[PatchOp[ResourceT], dict], |
| 922 | + check_request_payload: Optional[bool] = None, |
| 923 | + check_response_payload: Optional[bool] = None, |
| 924 | + expected_status_codes: Optional[ |
| 925 | + list[int] |
| 926 | + ] = SCIMClient.PATCH_RESPONSE_STATUS_CODES, |
| 927 | + raise_scim_errors: Optional[bool] = None, |
| 928 | + **kwargs, |
| 929 | + ) -> Optional[Union[ResourceT, Error, dict]]: |
| 930 | + """Perform a PATCH request to modify a resource, as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. |
| 931 | +
|
| 932 | + :param resource_model: The type of the resource to modify. |
| 933 | + :param id: The id of the resource to modify. |
| 934 | + :param patch_op: The :class:`~scim2_models.PatchOp` object describing the modifications. |
| 935 | + Must be parameterized with the same resource type as ``resource_model`` |
| 936 | + (e.g., :code:`PatchOp[User]` when ``resource_model`` is :code:`User`). |
| 937 | + :param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`. |
| 938 | + :param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`. |
| 939 | + :param expected_status_codes: The list of expected status codes form the response. |
| 940 | + If :data:`None` any status code is accepted. |
| 941 | + :param raise_scim_errors: If set, overwrites :paramref:`scim2_client.SCIMClient.raise_scim_errors`. |
| 942 | + :param kwargs: Additional parameters passed to the underlying |
| 943 | + HTTP request library. |
| 944 | +
|
| 945 | + :return: |
| 946 | + - An :class:`~scim2_models.Error` object in case of error. |
| 947 | + - The updated object as returned by the server in case of success if status code is 200. |
| 948 | + - :data:`None` in case of success if status code is 204. |
| 949 | +
|
| 950 | + :usage: |
| 951 | +
|
| 952 | + .. code-block:: python |
| 953 | + :caption: Modification of a `User` resource |
| 954 | +
|
| 955 | + from scim2_models import User, PatchOp, PatchOperation |
| 956 | +
|
| 957 | + operation = PatchOperation( |
| 958 | + op="replace", path="displayName", value="New Display Name" |
| 959 | + ) |
| 960 | + patch_op = PatchOp[User](operations=[operation]) |
| 961 | + response = scim.modify(User, "my-user-id", patch_op) |
| 962 | + # 'response' may be a User, None, or an Error object |
| 963 | +
|
| 964 | + .. tip:: |
| 965 | +
|
| 966 | + Check the :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST` |
| 967 | + and :attr:`~scim2_models.Context.RESOURCE_PATCH_RESPONSE` contexts to understand |
| 968 | + which value will excluded from the request payload, and which values are expected in |
| 969 | + the response payload. |
| 970 | + """ |
| 971 | + raise NotImplementedError() |
| 972 | + |
823 | 973 | def discover(self, schemas=True, resource_types=True, service_provider_config=True):
|
824 | 974 | """Dynamically discover the server configuration objects.
|
825 | 975 |
|
@@ -1097,6 +1247,62 @@ async def replace(
|
1097 | 1247 | """
|
1098 | 1248 | raise NotImplementedError()
|
1099 | 1249 |
|
| 1250 | + async def modify( |
| 1251 | + self, |
| 1252 | + resource_model: type[ResourceT], |
| 1253 | + id: str, |
| 1254 | + patch_op: Union[PatchOp[ResourceT], dict], |
| 1255 | + check_request_payload: Optional[bool] = None, |
| 1256 | + check_response_payload: Optional[bool] = None, |
| 1257 | + expected_status_codes: Optional[ |
| 1258 | + list[int] |
| 1259 | + ] = SCIMClient.PATCH_RESPONSE_STATUS_CODES, |
| 1260 | + raise_scim_errors: Optional[bool] = None, |
| 1261 | + **kwargs, |
| 1262 | + ) -> Optional[Union[ResourceT, Error, dict]]: |
| 1263 | + """Perform a PATCH request to modify a resource, as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. |
| 1264 | +
|
| 1265 | + :param resource_model: The type of the resource to modify. |
| 1266 | + :param id: The id of the resource to modify. |
| 1267 | + :param patch_op: The :class:`~scim2_models.PatchOp` object describing the modifications. |
| 1268 | + Must be parameterized with the same resource type as ``resource_model`` |
| 1269 | + (e.g., :code:`PatchOp[User]` when ``resource_model`` is :code:`User`). |
| 1270 | + :param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`. |
| 1271 | + :param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`. |
| 1272 | + :param expected_status_codes: The list of expected status codes form the response. |
| 1273 | + If :data:`None` any status code is accepted. |
| 1274 | + :param raise_scim_errors: If set, overwrites :paramref:`scim2_client.SCIMClient.raise_scim_errors`. |
| 1275 | + :param kwargs: Additional parameters passed to the underlying |
| 1276 | + HTTP request library. |
| 1277 | +
|
| 1278 | + :return: |
| 1279 | + - An :class:`~scim2_models.Error` object in case of error. |
| 1280 | + - The updated object as returned by the server in case of success if status code is 200. |
| 1281 | + - :data:`None` in case of success if status code is 204. |
| 1282 | +
|
| 1283 | + :usage: |
| 1284 | +
|
| 1285 | + .. code-block:: python |
| 1286 | + :caption: Modification of a `User` resource |
| 1287 | +
|
| 1288 | + from scim2_models import User, PatchOp, PatchOperation |
| 1289 | +
|
| 1290 | + operation = PatchOperation( |
| 1291 | + op="replace", path="displayName", value="New Display Name" |
| 1292 | + ) |
| 1293 | + patch_op = PatchOp[User](operations=[operation]) |
| 1294 | + response = await scim.modify(User, "my-user-id", patch_op) |
| 1295 | + # 'response' may be a User, None, or an Error object |
| 1296 | +
|
| 1297 | + .. tip:: |
| 1298 | +
|
| 1299 | + Check the :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST` |
| 1300 | + and :attr:`~scim2_models.Context.RESOURCE_PATCH_RESPONSE` contexts to understand |
| 1301 | + which value will excluded from the request payload, and which values are expected in |
| 1302 | + the response payload. |
| 1303 | + """ |
| 1304 | + raise NotImplementedError() |
| 1305 | + |
1100 | 1306 | async def discover(
|
1101 | 1307 | self, schemas=True, resource_types=True, service_provider_config=True
|
1102 | 1308 | ):
|
|
0 commit comments