scim2-client 0.1.3__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: scim2-client
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Pythonically build SCIM requests and parse SCIM responses
5
5
  License: MIT
6
6
  Keywords: scim,scim2,provisioning,httpx,api
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.11
20
20
  Classifier: Programming Language :: Python :: 3.12
21
21
  Classifier: Programming Language :: Python :: Implementation :: CPython
22
22
  Requires-Dist: httpx (>=0.27.0,<0.28.0)
23
- Requires-Dist: scim2-models (>=0.1.2,<0.2.0)
23
+ Requires-Dist: scim2-models (>=0.1.5,<0.2.0)
24
24
  Description-Content-Type: text/markdown
25
25
 
26
26
  # scim2-client
@@ -28,6 +28,16 @@ Description-Content-Type: text/markdown
28
28
  A SCIM client Python library built upon [scim2-models](https://scim2-models.readthedocs.io) and [httpx](https://github.com/encode/httpx),
29
29
  that pythonically build requests and parse responses,
30
30
  following the [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643.html) and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644.html) specifications.
31
+
32
+ It aims to be used in SCIM client applications, or in unit tests for SCIM server applications.
33
+
34
+ ## What's SCIM anyway?
35
+
36
+ SCIM stands for System for Cross-domain Identity Management, and it is a provisioning protocol.
37
+ Provisioning is the action of managing a set of resources across different services, usually users and groups.
38
+ SCIM is often used between Identity Providers and applications in completion of standards like OAuth2 and OpenID Connect.
39
+ It allows users and groups creations, modifications and deletions to be synchronized between applications.
40
+
31
41
  ## Installation
32
42
 
33
43
  ```shell
@@ -65,8 +75,14 @@ assert user.meta.last_updated == datetime.datetime(
65
75
  )
66
76
 
67
77
  # Create resources
68
- response = scim.create(User, "2819c223-7f76-453a-919d-413861904646")
78
+ payload = User(user_name="bjensen@example.com")
79
+ response = scim.create(user)
69
80
  assert isinstance(response, Error)
70
81
  assert response.detail == "One or more of the attribute values are already in use or are reserved."
71
82
  ```
72
83
 
84
+ scim2-client belongs in a collection of SCIM tools developed by [Yaal Coop](https://yaal.coop),
85
+ with [scim2-models](https://github.com/yaal-coop/scim2-models),
86
+ [scim2-tester](https://github.com/yaal-coop/scim2-tester) and
87
+ [scim2-cli](https://github.com/yaal-coop/scim2-cli)
88
+
@@ -3,6 +3,16 @@
3
3
  A SCIM client Python library built upon [scim2-models](https://scim2-models.readthedocs.io) and [httpx](https://github.com/encode/httpx),
4
4
  that pythonically build requests and parse responses,
5
5
  following the [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643.html) and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644.html) specifications.
6
+
7
+ It aims to be used in SCIM client applications, or in unit tests for SCIM server applications.
8
+
9
+ ## What's SCIM anyway?
10
+
11
+ SCIM stands for System for Cross-domain Identity Management, and it is a provisioning protocol.
12
+ Provisioning is the action of managing a set of resources across different services, usually users and groups.
13
+ SCIM is often used between Identity Providers and applications in completion of standards like OAuth2 and OpenID Connect.
14
+ It allows users and groups creations, modifications and deletions to be synchronized between applications.
15
+
6
16
  ## Installation
7
17
 
8
18
  ```shell
@@ -40,7 +50,13 @@ assert user.meta.last_updated == datetime.datetime(
40
50
  )
41
51
 
42
52
  # Create resources
43
- response = scim.create(User, "2819c223-7f76-453a-919d-413861904646")
53
+ payload = User(user_name="bjensen@example.com")
54
+ response = scim.create(user)
44
55
  assert isinstance(response, Error)
45
56
  assert response.detail == "One or more of the attribute values are already in use or are reserved."
46
57
  ```
58
+
59
+ scim2-client belongs in a collection of SCIM tools developed by [Yaal Coop](https://yaal.coop),
60
+ with [scim2-models](https://github.com/yaal-coop/scim2-models),
61
+ [scim2-tester](https://github.com/yaal-coop/scim2-tester) and
62
+ [scim2-cli](https://github.com/yaal-coop/scim2-cli)
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "scim2-client"
7
- version = "0.1.3"
7
+ version = "0.1.5"
8
8
  description = "Pythonically build SCIM requests and parse SCIM responses"
9
9
  authors = ["Yaal Coop <contact@yaal.coop>"]
10
10
  license = "MIT"
@@ -27,7 +27,7 @@ classifiers = [
27
27
  [tool.poetry.dependencies]
28
28
  python = "^3.9"
29
29
  httpx = "^0.27.0"
30
- scim2-models = "^0.1.2"
30
+ scim2-models = "^0.1.5"
31
31
 
32
32
  [tool.poetry.group.doc]
33
33
  optional = true
@@ -41,6 +41,7 @@ pytest-httpserver = "^1.0.10"
41
41
  shibuya = "^2024.5.15"
42
42
  sphinx = "^7.3.7"
43
43
  myst-parser = "^3.0.1"
44
+ autodoc-pydantic = "^2.2.0"
44
45
 
45
46
  [tool.coverage.run]
46
47
  source = [
@@ -0,0 +1,23 @@
1
+ from .client import SCIMClient
2
+ from .errors import RequestNetworkError
3
+ from .errors import RequestPayloadValidationError
4
+ from .errors import ResponsePayloadValidationError
5
+ from .errors import SCIMClientError
6
+ from .errors import SCIMRequestError
7
+ from .errors import SCIMResponseError
8
+ from .errors import UnexpectedContentFormat
9
+ from .errors import UnexpectedContentType
10
+ from .errors import UnexpectedStatusCode
11
+
12
+ __all__ = [
13
+ "SCIMClient",
14
+ "SCIMClientError",
15
+ "SCIMRequestError",
16
+ "SCIMResponseError",
17
+ "UnexpectedContentFormat",
18
+ "UnexpectedContentType",
19
+ "UnexpectedStatusCode",
20
+ "RequestPayloadValidationError",
21
+ "RequestNetworkError",
22
+ "ResponsePayloadValidationError",
23
+ ]
@@ -8,6 +8,7 @@ from typing import Type
8
8
  from typing import Union
9
9
 
10
10
  from httpx import Client
11
+ from httpx import RequestError
11
12
  from httpx import Response
12
13
  from pydantic import ValidationError
13
14
  from scim2_models import AnyResource
@@ -16,9 +17,17 @@ from scim2_models import Error
16
17
  from scim2_models import ListResponse
17
18
  from scim2_models import PatchOp
18
19
  from scim2_models import Resource
20
+ from scim2_models import ResourceType
21
+ from scim2_models import Schema
19
22
  from scim2_models import SearchRequest
23
+ from scim2_models import ServiceProviderConfig
20
24
 
25
+ from .errors import RequestNetworkError
26
+ from .errors import RequestPayloadValidationError
27
+ from .errors import ResponsePayloadValidationError
21
28
  from .errors import SCIMClientError
29
+ from .errors import SCIMRequestError
30
+ from .errors import SCIMResponseError
22
31
  from .errors import UnexpectedContentFormat
23
32
  from .errors import UnexpectedContentType
24
33
  from .errors import UnexpectedStatusCode
@@ -30,7 +39,16 @@ BASE_HEADERS = {
30
39
 
31
40
 
32
41
  class SCIMClient:
33
- """An object that perform SCIM requests and validate responses."""
42
+ """An object that perform SCIM requests and validate responses.
43
+
44
+ :param client: A :class:`httpx.Client` instance that will be used to send requests.
45
+ :param resource_types: A tuple of :class:`~scim2_models.Resource` types expected to be handled by the SCIMClient.
46
+ If a request payload describe a resource that is not in this list, an exception will be raised.
47
+
48
+ .. note::
49
+
50
+ :class:`~scim2_models.ResourceType`, :class:`~scim2_models.Schema` and :class:`scim2_models.ServiceProviderConfig` are pre-loaded by default.
51
+ """
34
52
 
35
53
  CREATION_RESPONSE_STATUS_CODES: List[int] = [
36
54
  201,
@@ -99,24 +117,39 @@ class SCIMClient:
99
117
 
100
118
  def __init__(self, client: Client, resource_types: Optional[Tuple[Type]] = None):
101
119
  self.client = client
102
- self.resource_types = resource_types or ()
120
+ self.resource_types = tuple(
121
+ set(resource_types or []) | {ResourceType, Schema, ServiceProviderConfig}
122
+ )
103
123
 
104
124
  def check_resource_type(self, resource_type):
105
125
  if resource_type not in self.resource_types:
106
- raise ValueError(f"Unknown resource type: '{resource_type}'")
126
+ raise SCIMRequestError(f"Unknown resource type: '{resource_type}'")
107
127
 
108
128
  def resource_endpoint(self, resource_type: Type):
109
- return f"/{resource_type.__name__}s"
129
+ if resource_type is None:
130
+ return "/"
131
+
132
+ # This one takes no final 's'
133
+ if resource_type is ServiceProviderConfig:
134
+ return "/ServiceProviderConfig"
135
+
136
+ try:
137
+ first_bracket_index = resource_type.__name__.index("[")
138
+ root_name = resource_type.__name__[:first_bracket_index]
139
+ except ValueError:
140
+ root_name = resource_type.__name__
141
+ return f"/{root_name}s"
110
142
 
111
143
  def check_response(
112
144
  self,
113
145
  response: Response,
114
146
  expected_status_codes: List[int],
115
- expected_type: Optional[Type] = None,
147
+ expected_types: Optional[Type] = None,
148
+ check_response_payload: bool = True,
116
149
  scim_ctx: Optional[Context] = None,
117
150
  ):
118
151
  if expected_status_codes and response.status_code not in expected_status_codes:
119
- raise UnexpectedStatusCode(response)
152
+ raise UnexpectedStatusCode(source=response)
120
153
 
121
154
  # Interoperability considerations: The "application/scim+json" media
122
155
  # type is intended to identify JSON structure data that conforms to
@@ -126,7 +159,7 @@ class SCIMClient:
126
159
 
127
160
  expected_response_content_types = ("application/scim+json", "application/json")
128
161
  if response.headers.get("content-type") not in expected_response_content_types:
129
- raise UnexpectedContentType(response)
162
+ raise UnexpectedContentType(source=response)
130
163
 
131
164
  # In addition to returning an HTTP response code, implementers MUST return
132
165
  # the errors in the body of the response in a JSON format
@@ -140,21 +173,42 @@ class SCIMClient:
140
173
  try:
141
174
  response_payload = response.json()
142
175
  except json.decoder.JSONDecodeError as exc:
143
- raise UnexpectedContentFormat(response) from exc
176
+ raise UnexpectedContentFormat(source=response) from exc
177
+
178
+ if not check_response_payload:
179
+ return response_payload
144
180
 
145
181
  try:
146
182
  return Error.model_validate(response_payload)
147
183
  except ValidationError:
148
184
  pass
149
185
 
150
- if expected_type:
186
+ if not expected_types:
187
+ return response_payload
188
+
189
+ actual_type = Resource.get_by_payload(
190
+ expected_types, response_payload, with_extensions=False
191
+ )
192
+
193
+ if not actual_type:
194
+ expected = ", ".join([type.__name__ for type in expected_types])
151
195
  try:
152
- return expected_type.model_validate(response_payload, scim_ctx=scim_ctx)
153
- except ValidationError as exc:
154
- exc.response_payload = response_payload
155
- raise exc
196
+ schema = ", ".join(response_payload["schemas"])
197
+ message = f"Expected type {expected} but got unknow resource with schemas: {schema}"
198
+ except KeyError:
199
+ message = (
200
+ f"Expected type {expected} but got undefined object with no schema"
201
+ )
156
202
 
157
- return response_payload
203
+ raise SCIMResponseError(message, source=response)
204
+
205
+ try:
206
+ return actual_type.model_validate(response_payload, scim_ctx=scim_ctx)
207
+ except ValidationError as exc:
208
+ scim_exc = ResponsePayloadValidationError(source=response)
209
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
210
+ scim_exc.add_note(str(exc))
211
+ raise scim_exc from exc
158
212
 
159
213
  def create(
160
214
  self,
@@ -179,7 +233,24 @@ class SCIMClient:
179
233
 
180
234
  :return:
181
235
  - An :class:`~scim2_models.Error` object in case of error.
182
- - The created object as returned by the server in case of success.
236
+ - The created object as returned by the server in case of success and :code:`check_response_payload` is :data:`True`.
237
+ - The created object payload as returned by the server in case of success and :code:`check_response_payload` is :data:`False`.
238
+
239
+ .. code-block:: python
240
+ :caption: Creation of a `User` resource
241
+
242
+ from scim2_models import User
243
+
244
+ request = User(user_name="bjensen@example.com")
245
+ response = scim.create(request)
246
+ # 'response' may be a User or an Error object
247
+
248
+ .. tip::
249
+
250
+ Check the :attr:`~scim2_models.Context.RESOURCE_CREATION_REQUEST`
251
+ and :attr:`~scim2_models.Context.RESOURCE_CREATION_RESPONSE` contexts to understand
252
+ which value will excluded from the request payload, and which values are expected in
253
+ the response payload.
183
254
  """
184
255
 
185
256
  if not check_request_payload:
@@ -193,30 +264,43 @@ class SCIMClient:
193
264
  else:
194
265
  resource_type = Resource.get_by_payload(self.resource_types, resource)
195
266
  if not resource_type:
196
- raise SCIMClientError(
197
- None, "Cannot guess resource type from the payload"
267
+ raise SCIMRequestError(
268
+ "Cannot guess resource type from the payload"
198
269
  )
199
270
 
200
- resource = resource_type.model_validate(resource)
271
+ try:
272
+ resource = resource_type.model_validate(resource)
273
+ except ValidationError as exc:
274
+ scim_exc = RequestPayloadValidationError(source=resource)
275
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
276
+ scim_exc.add_note(str(exc))
277
+ raise scim_exc from exc
201
278
 
202
279
  self.check_resource_type(resource_type)
203
280
  url = kwargs.pop("url", self.resource_endpoint(resource_type))
204
281
  payload = resource.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST)
205
282
 
206
- response = self.client.post(url, json=payload, **kwargs)
283
+ try:
284
+ response = self.client.post(url, json=payload, **kwargs)
285
+ except RequestError as exc:
286
+ scim_exc = RequestNetworkError(source=payload)
287
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
288
+ scim_exc.add_note(str(exc))
289
+ raise scim_exc from exc
207
290
 
208
291
  return self.check_response(
209
- response,
210
- self.CREATION_RESPONSE_STATUS_CODES if check_status_code else None,
211
- resource.__class__
212
- if check_request_payload and check_response_payload
213
- else None,
292
+ response=response,
293
+ expected_status_codes=(
294
+ self.CREATION_RESPONSE_STATUS_CODES if check_status_code else None
295
+ ),
296
+ expected_types=([resource.__class__] if check_request_payload else None),
297
+ check_response_payload=check_response_payload,
214
298
  scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
215
299
  )
216
300
 
217
301
  def query(
218
302
  self,
219
- resource_type: Type,
303
+ resource_type: Optional[Type] = None,
220
304
  id: Optional[str] = None,
221
305
  search_request: Optional[Union[SearchRequest, Dict]] = None,
222
306
  check_request_payload: bool = True,
@@ -244,66 +328,49 @@ class SCIMClient:
244
328
  - A :class:`~scim2_models.Error` object in case of error.
245
329
  - A `resource_type` object in case of success if `id` is not :data:`None`
246
330
  - A :class:`~scim2_models.ListResponse[resource_type]` object in case of success if `id` is :data:`None`
247
- """
248
331
 
249
- self.check_resource_type(resource_type)
250
- if not check_request_payload:
251
- payload = search_request
332
+ .. note::
252
333
 
253
- else:
254
- payload = (
255
- search_request.model_dump(
256
- exclude_unset=True,
257
- scim_ctx=Context.RESOURCE_QUERY_REQUEST,
258
- )
259
- if search_request
260
- else None
261
- )
334
+ Querying a :class:`~scim2_models.ServiceProviderConfig` will return a
335
+ single object, and not a :class:`~scim2_models.ListResponse`.
262
336
 
263
- if not id:
264
- expected_type = ListResponse[resource_type]
265
- url = self.resource_endpoint(resource_type)
337
+ :usage:
266
338
 
267
- else:
268
- expected_type = resource_type
269
- url = self.resource_endpoint(resource_type) + f"/{id}"
339
+ .. code-block:: python
340
+ :caption: Query of a `User` resource knowing its id
270
341
 
271
- response = self.client.get(url, params=payload, **kwargs)
272
- return self.check_response(
273
- response,
274
- self.QUERY_RESPONSE_STATUS_CODES if check_status_code else None,
275
- expected_type if check_response_payload else None,
276
- scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
277
- )
342
+ from scim2_models import User
278
343
 
279
- def query_all(
280
- self,
281
- search_request: Optional[SearchRequest] = None,
282
- check_request_payload: bool = True,
283
- check_response_payload: bool = True,
284
- check_status_code: bool = True,
285
- **kwargs,
286
- ) -> Union[AnyResource, ListResponse[AnyResource], Error, Dict]:
287
- """Perform a GET request to read all available resources, as defined in
288
- :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>`.
344
+ response = scim.query(User, "my-user-id)
345
+ # 'response' may be a User or an Error object
289
346
 
290
- :param search_request: An object detailing the search query parameters.
291
- :param check_request_payload: If :data:`False`,
292
- :code:`search_request` is expected to be a dict that will be passed as-is in the request.
293
- :param check_response_payload: Whether to validate that the response payload is valid.
294
- If set, the raw payload will be returned.
295
- :param check_status_code: Whether to validate that the response status code is valid.
296
- :param kwargs: Additional parameters passed to the underlying
297
- HTTP request library.
347
+ .. code-block:: python
348
+ :caption: Query of all the `User` resources filtering the ones with `userName` starts with `john`
298
349
 
299
- :return:
300
- - A :class:`~scim2_models.Error` object in case of error.
301
- - A :class:`~scim2_models.ListResponse[resource_type]` object in case of success.
350
+ from scim2_models import User, SearchRequest
351
+
352
+ req = SearchRequest(filter='userName sw "john"')
353
+ response = scim.query(User, search_request=search_request)
354
+ # 'response' may be a ListResponse[User] or an Error object
355
+
356
+ .. code-block:: python
357
+ :caption: Query of all the available resources
358
+
359
+ from scim2_models import User, SearchRequest
360
+
361
+ response = scim.query()
362
+ # 'response' may be a ListResponse[Union[User, Group, ...]] or an Error object
363
+
364
+ .. tip::
365
+
366
+ Check the :attr:`~scim2_models.Context.RESOURCE_QUERY_REQUEST`
367
+ and :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE` contexts to understand
368
+ which value will excluded from the request payload, and which values are expected in
369
+ the response payload.
302
370
  """
303
371
 
304
- # A query against a server root indicates that all resources within the
305
- # server SHALL be included, subject to filtering.
306
- # https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.4.2.1
372
+ if resource_type and check_request_payload:
373
+ self.check_resource_type(resource_type)
307
374
 
308
375
  if not check_request_payload:
309
376
  payload = search_request
@@ -311,20 +378,48 @@ class SCIMClient:
311
378
  else:
312
379
  payload = (
313
380
  search_request.model_dump(
314
- exclude_unset=True, scim_ctx=Context.RESOURCE_QUERY_REQUEST
381
+ exclude_unset=True,
382
+ scim_ctx=Context.RESOURCE_QUERY_REQUEST,
315
383
  )
316
384
  if search_request
317
385
  else None
318
386
  )
319
387
 
320
- response = self.client.get("/", params=payload)
388
+ url = kwargs.pop("url", self.resource_endpoint(resource_type))
389
+
390
+ if resource_type is None:
391
+ expected_types = [
392
+ *self.resource_types,
393
+ ListResponse[Union[self.resource_types]],
394
+ ]
395
+
396
+ elif resource_type == ServiceProviderConfig:
397
+ expected_types = [resource_type]
398
+ if id:
399
+ raise SCIMClientError("ServiceProviderConfig cannot have an id")
400
+
401
+ elif id:
402
+ expected_types = [resource_type]
403
+ url = f"{url}/{id}"
404
+
405
+ else:
406
+ expected_types = [ListResponse[resource_type]]
407
+
408
+ try:
409
+ response = self.client.get(url, params=payload, **kwargs)
410
+ except RequestError as exc:
411
+ scim_exc = RequestNetworkError(source=payload)
412
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
413
+ scim_exc.add_note(str(exc))
414
+ raise scim_exc from exc
321
415
 
322
416
  return self.check_response(
323
- response,
324
- self.QUERY_RESPONSE_STATUS_CODES if check_status_code else None,
325
- ListResponse[Union[self.resource_types]]
326
- if check_response_payload
327
- else None,
417
+ response=response,
418
+ expected_status_codes=(
419
+ self.QUERY_RESPONSE_STATUS_CODES if check_status_code else None
420
+ ),
421
+ expected_types=expected_types,
422
+ check_response_payload=check_response_payload,
328
423
  scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
329
424
  )
330
425
 
@@ -353,6 +448,24 @@ class SCIMClient:
353
448
  :return:
354
449
  - A :class:`~scim2_models.Error` object in case of error.
355
450
  - A :class:`~scim2_models.ListResponse[resource_type]` object in case of success.
451
+
452
+ :usage:
453
+
454
+ .. code-block:: python
455
+ :caption: Searching for all the resources filtering the ones with `id` contains with `admin`
456
+
457
+ from scim2_models import User, SearchRequest
458
+
459
+ req = SearchRequest(filter='id co "john"')
460
+ response = scim.search(search_request=search_request)
461
+ # 'response' may be a ListResponse[User] or an Error object
462
+
463
+ .. tip::
464
+
465
+ Check the :attr:`~scim2_models.Context.SEARCH_REQUEST`
466
+ and :attr:`~scim2_models.Context.SEARCH_RESPONSE` contexts to understand
467
+ which value will excluded from the request payload, and which values are expected in
468
+ the response payload.
356
469
  """
357
470
 
358
471
  if not check_request_payload:
@@ -367,23 +480,41 @@ class SCIMClient:
367
480
  else None
368
481
  )
369
482
 
370
- response = self.client.post("/.search", json=payload)
483
+ url = kwargs.pop("url", "/.search")
484
+
485
+ try:
486
+ response = self.client.post(url, json=payload)
487
+ except RequestError as exc:
488
+ scim_exc = RequestNetworkError(source=payload)
489
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
490
+ scim_exc.add_note(str(exc))
491
+ raise scim_exc from exc
371
492
 
372
493
  return self.check_response(
373
- response,
374
- self.SEARCH_RESPONSE_STATUS_CODES if check_status_code else None,
375
- ListResponse[Union[self.resource_types]]
376
- if check_response_payload
377
- else None,
494
+ response=response,
495
+ expected_status_codes=(
496
+ self.SEARCH_RESPONSE_STATUS_CODES if check_status_code else None
497
+ ),
498
+ expected_types=[ListResponse[Union[self.resource_types]]],
499
+ check_response_payload=check_response_payload,
378
500
  scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
379
501
  )
380
502
 
381
503
  def delete(
382
- self, resource_type: Type, id: str, check_status_code: bool = True, **kwargs
504
+ self,
505
+ resource_type: Type,
506
+ id: str,
507
+ check_response_payload: bool = True,
508
+ check_status_code: bool = True,
509
+ **kwargs,
383
510
  ) -> Optional[Union[Error, Dict]]:
384
511
  """Perform a DELETE request to create, as defined in :rfc:`RFC7644 §3.6
385
512
  <7644#section-3.6>`.
386
513
 
514
+ :param resource_type: The type of the resource to delete.
515
+ :param id: The type id the resource to delete.
516
+ :param check_response_payload: Whether to validate that the response payload is valid.
517
+ If set, the raw payload will be returned.
387
518
  :param check_status_code: Whether to validate that the response status code is valid.
388
519
  :param kwargs: Additional parameters passed to the underlying
389
520
  HTTP request library.
@@ -391,14 +522,36 @@ class SCIMClient:
391
522
  :return:
392
523
  - A :class:`~scim2_models.Error` object in case of error.
393
524
  - :data:`None` in case of success.
525
+
526
+ :usage:
527
+
528
+ .. code-block:: python
529
+ :caption: Deleting an `User` which `id` is `foobar`
530
+
531
+ from scim2_models import User, SearchRequest
532
+
533
+ response = scim.delete(User, "foobar")
534
+ # 'response' may be None, or an Error object
394
535
  """
395
536
 
396
537
  self.check_resource_type(resource_type)
397
- url = self.resource_endpoint(resource_type) + f"/{id}"
398
- response = self.client.delete(url, **kwargs)
538
+ delete_url = self.resource_endpoint(resource_type) + f"/{id}"
539
+ url = kwargs.pop("url", delete_url)
540
+
541
+ try:
542
+ response = self.client.delete(url, **kwargs)
543
+ except RequestError as exc:
544
+ scim_exc = RequestNetworkError()
545
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
546
+ scim_exc.add_note(str(exc))
547
+ raise scim_exc from exc
399
548
 
400
549
  return self.check_response(
401
- response, self.DELETION_RESPONSE_STATUS_CODES if check_status_code else None
550
+ response=response,
551
+ expected_status_codes=(
552
+ self.DELETION_RESPONSE_STATUS_CODES if check_status_code else None
553
+ ),
554
+ check_response_payload=check_response_payload,
402
555
  )
403
556
 
404
557
  def replace(
@@ -425,6 +578,25 @@ class SCIMClient:
425
578
  :return:
426
579
  - An :class:`~scim2_models.Error` object in case of error.
427
580
  - The updated object as returned by the server in case of success.
581
+
582
+ :usage:
583
+
584
+ .. code-block:: python
585
+ :caption: Replacement of a `User` resource
586
+
587
+ from scim2_models import User
588
+
589
+ user = scim.query(User, "my-used-id")
590
+ user.display_name = "Fancy New Name"
591
+ response = scim.update(user)
592
+ # 'response' may be a User or an Error object
593
+
594
+ .. tip::
595
+
596
+ Check the :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`
597
+ and :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_RESPONSE` contexts to understand
598
+ which value will excluded from the request payload, and which values are expected in
599
+ the response payload.
428
600
  """
429
601
 
430
602
  if not check_request_payload:
@@ -438,30 +610,44 @@ class SCIMClient:
438
610
  else:
439
611
  resource_type = Resource.get_by_payload(self.resource_types, resource)
440
612
  if not resource_type:
441
- raise SCIMClientError(
442
- None, "Cannot guess resource type from the payload"
613
+ raise SCIMRequestError(
614
+ "Cannot guess resource type from the payload",
615
+ source=resource,
443
616
  )
444
617
 
445
- resource = resource_type.model_validate(resource)
618
+ try:
619
+ resource = resource_type.model_validate(resource)
620
+ except ValidationError as exc:
621
+ scim_exc = RequestPayloadValidationError(source=resource)
622
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
623
+ scim_exc.add_note(str(exc))
624
+ raise scim_exc from exc
446
625
 
447
626
  self.check_resource_type(resource_type)
448
627
 
449
628
  if not resource.id:
450
- raise SCIMClientError(None, "Resource must have an id")
629
+ raise SCIMRequestError("Resource must have an id", source=resource)
451
630
 
452
631
  payload = resource.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST)
453
632
  url = kwargs.pop(
454
633
  "url", self.resource_endpoint(resource.__class__) + f"/{resource.id}"
455
634
  )
456
635
 
457
- response = self.client.put(url, json=payload, **kwargs)
636
+ try:
637
+ response = self.client.put(url, json=payload, **kwargs)
638
+ except RequestError as exc:
639
+ scim_exc = RequestNetworkError(source=payload)
640
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
641
+ scim_exc.add_note(str(exc))
642
+ raise scim_exc from exc
458
643
 
459
644
  return self.check_response(
460
- response,
461
- self.REPLACEMENT_RESPONSE_STATUS_CODES if check_status_code else None,
462
- resource.__class__
463
- if check_request_payload and check_response_payload
464
- else None,
645
+ response=response,
646
+ expected_status_codes=(
647
+ self.REPLACEMENT_RESPONSE_STATUS_CODES if check_status_code else None
648
+ ),
649
+ expected_types=([resource.__class__] if check_request_payload else None),
650
+ check_response_payload=check_response_payload,
465
651
  scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
466
652
  )
467
653
 
@@ -0,0 +1,111 @@
1
+ from typing import Any
2
+
3
+
4
+ class SCIMClientError(Exception):
5
+ """Base exception for scim2-client.
6
+
7
+ :param message: The exception reason.
8
+ :param source: The request payload or the response object that have
9
+ caused the exception.
10
+ """
11
+
12
+ def __init__(self, message: str, source: Any = None, *args, **kwargs):
13
+ self.message = message
14
+ self.source = source
15
+ super().__init__(*args, **kwargs)
16
+
17
+ def __str__(self):
18
+ return self.message or "UNKNOWN"
19
+
20
+
21
+ class SCIMRequestError(SCIMClientError):
22
+ """Base exception for errors happening during request payload building."""
23
+
24
+
25
+ class RequestNetworkError(SCIMRequestError):
26
+ """Error raised when a network error happened during request.
27
+
28
+ This error is raised when a :class:`httpx.RequestError` has been catched while performing a request.
29
+ The original :class:`~httpx.RequestError` is available with :attr:`~BaseException.__cause__`.
30
+ """
31
+
32
+ def __init__(self, *args, **kwargs):
33
+ message = kwargs.pop("message", "Network error happened during request")
34
+ super().__init__(message, *args, **kwargs)
35
+
36
+
37
+ class RequestPayloadValidationError(SCIMRequestError):
38
+ """Error raised when an invalid request payload has been passed to
39
+ SCIMClient.
40
+
41
+ This error is raised when a :class:`pydantic.ValidationError` has been catched
42
+ while validating the client request payload.
43
+ The original :class:`~pydantic.ValidationError` is available with :attr:`~BaseException.__cause__`.
44
+
45
+ .. code-block:: python
46
+
47
+ try:
48
+ scim.create({"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "active": "not-a-bool"})
49
+ except RequestPayloadValidationError as exc:
50
+ print("Original validation error cause", exc.__cause__)
51
+ """
52
+
53
+ def __init__(self, *args, **kwargs):
54
+ message = kwargs.pop("message", "Server response payload validation error")
55
+ super().__init__(message, *args, **kwargs)
56
+
57
+
58
+ class SCIMResponseError(SCIMClientError):
59
+ """Base exception for errors happening during response payload
60
+ validation."""
61
+
62
+
63
+ class UnexpectedStatusCode(SCIMResponseError):
64
+ """Error raised when a server returned an unexpected status code for a
65
+ given :class:`~scim2_models.Context`."""
66
+
67
+ def __init__(self, *args, **kwargs):
68
+ message = kwargs.pop(
69
+ "message",
70
+ f"Unexpected response status code: {kwargs['source'].status_code}",
71
+ )
72
+ super().__init__(message, *args, **kwargs)
73
+
74
+
75
+ class UnexpectedContentType(SCIMResponseError):
76
+ """Error raised when a server returned an unexpected `Content-Type` header
77
+ in a response."""
78
+
79
+ def __init__(self, *args, **kwargs):
80
+ content_type = kwargs["source"].headers.get("content-type", "")
81
+ message = kwargs.pop("message", f"Unexpected content type: {content_type}")
82
+ super().__init__(message, *args, **kwargs)
83
+
84
+
85
+ class UnexpectedContentFormat(SCIMResponseError):
86
+ """Error raised when a server returned a response in a non-JSON format."""
87
+
88
+ def __init__(self, *args, **kwargs):
89
+ message = kwargs.pop("message", "Unexpected response content format")
90
+ super().__init__(message, *args, **kwargs)
91
+
92
+
93
+ class ResponsePayloadValidationError(SCIMResponseError):
94
+ """Error raised when the server returned a payload that cannot be
95
+ validated.
96
+
97
+ This error is raised when a :class:`pydantic.ValidationError` has been catched
98
+ while validating the server response payload.
99
+ The original :class:`~pydantic.ValidationError` is available with :attr:`~BaseException.__cause__`.
100
+
101
+ .. code-block:: python
102
+
103
+ try:
104
+ scim.query(User, "foobar")
105
+ except ResponsePayloadValidationError as exc:
106
+ print("Original validation error cause", exc.__cause__)
107
+ """
108
+
109
+ def __init__(self, *args, **kwargs):
110
+ message = kwargs.pop("message", "Server response payload validation error")
111
+ super().__init__(message, *args, **kwargs)
@@ -1,13 +0,0 @@
1
- from .client import SCIMClient
2
- from .errors import SCIMClientError
3
- from .errors import UnexpectedContentFormat
4
- from .errors import UnexpectedContentType
5
- from .errors import UnexpectedStatusCode
6
-
7
- __all__ = [
8
- "SCIMClient",
9
- "SCIMClientError",
10
- "UnexpectedStatusCode",
11
- "UnexpectedContentType",
12
- "UnexpectedContentFormat",
13
- ]
@@ -1,43 +0,0 @@
1
- from httpx import Response
2
-
3
-
4
- class SCIMClientError(Exception):
5
- def __init__(self, response: Response, *args, **kwargs):
6
- self.response = response
7
- super().__init__(*args, **kwargs)
8
-
9
-
10
- class UnexpectedStatusCode(SCIMClientError):
11
- def __init__(
12
- self,
13
- response: Response,
14
- *args,
15
- **kwargs,
16
- ):
17
- message = kwargs.pop(
18
- "message", f"Unexpected response status code: {response.status_code}"
19
- )
20
- super().__init__(response, message, *args, **kwargs)
21
-
22
-
23
- class UnexpectedContentType(SCIMClientError):
24
- def __init__(
25
- self,
26
- response: Response,
27
- *args,
28
- **kwargs,
29
- ):
30
- content_type = response.headers.get("content-type", "")
31
- message = kwargs.pop("message", f"Unexpected content type: {content_type}")
32
- super().__init__(response, message, *args, **kwargs)
33
-
34
-
35
- class UnexpectedContentFormat(SCIMClientError):
36
- def __init__(
37
- self,
38
- response: Response,
39
- *args,
40
- **kwargs,
41
- ):
42
- message = kwargs.pop("message", "Unexpected response content format")
43
- super().__init__(response, message, *args, **kwargs)