scim2-client 0.1.4__py3-none-any.whl → 0.1.6__py3-none-any.whl

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.
scim2_client/__init__.py CHANGED
@@ -1,5 +1,11 @@
1
1
  from .client import SCIMClient
2
+ from .errors import RequestNetworkError
3
+ from .errors import RequestPayloadValidationError
4
+ from .errors import ResponsePayloadValidationError
2
5
  from .errors import SCIMClientError
6
+ from .errors import SCIMRequestError
7
+ from .errors import SCIMResponseError
8
+ from .errors import SCIMResponseErrorObject
3
9
  from .errors import UnexpectedContentFormat
4
10
  from .errors import UnexpectedContentType
5
11
  from .errors import UnexpectedStatusCode
@@ -7,7 +13,13 @@ from .errors import UnexpectedStatusCode
7
13
  __all__ = [
8
14
  "SCIMClient",
9
15
  "SCIMClientError",
10
- "UnexpectedStatusCode",
11
- "UnexpectedContentType",
16
+ "SCIMRequestError",
17
+ "SCIMResponseError",
18
+ "SCIMResponseErrorObject",
12
19
  "UnexpectedContentFormat",
20
+ "UnexpectedContentType",
21
+ "UnexpectedStatusCode",
22
+ "RequestPayloadValidationError",
23
+ "RequestNetworkError",
24
+ "ResponsePayloadValidationError",
13
25
  ]
scim2_client/client.py CHANGED
@@ -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,18 @@ 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
31
+ from .errors import SCIMResponseErrorObject
22
32
  from .errors import UnexpectedContentFormat
23
33
  from .errors import UnexpectedContentType
24
34
  from .errors import UnexpectedStatusCode
@@ -30,7 +40,16 @@ BASE_HEADERS = {
30
40
 
31
41
 
32
42
  class SCIMClient:
33
- """An object that perform SCIM requests and validate responses."""
43
+ """An object that perform SCIM requests and validate responses.
44
+
45
+ :param client: A :class:`httpx.Client` instance that will be used to send requests.
46
+ :param resource_types: A tuple of :class:`~scim2_models.Resource` types expected to be handled by the SCIMClient.
47
+ If a request payload describe a resource that is not in this list, an exception will be raised.
48
+
49
+ .. note::
50
+
51
+ :class:`~scim2_models.ResourceType`, :class:`~scim2_models.Schema` and :class:`scim2_models.ServiceProviderConfig` are pre-loaded by default.
52
+ """
34
53
 
35
54
  CREATION_RESPONSE_STATUS_CODES: List[int] = [
36
55
  201,
@@ -99,13 +118,22 @@ class SCIMClient:
99
118
 
100
119
  def __init__(self, client: Client, resource_types: Optional[Tuple[Type]] = None):
101
120
  self.client = client
102
- self.resource_types = resource_types or ()
121
+ self.resource_types = tuple(
122
+ set(resource_types or []) | {ResourceType, Schema, ServiceProviderConfig}
123
+ )
103
124
 
104
125
  def check_resource_type(self, resource_type):
105
126
  if resource_type not in self.resource_types:
106
- raise ValueError(f"Unknown resource type: '{resource_type}'")
127
+ raise SCIMRequestError(f"Unknown resource type: '{resource_type}'")
107
128
 
108
129
  def resource_endpoint(self, resource_type: Type):
130
+ if resource_type is None:
131
+ return "/"
132
+
133
+ # This one takes no final 's'
134
+ if resource_type is ServiceProviderConfig:
135
+ return "/ServiceProviderConfig"
136
+
109
137
  try:
110
138
  first_bracket_index = resource_type.__name__.index("[")
111
139
  root_name = resource_type.__name__[:first_bracket_index]
@@ -117,11 +145,13 @@ class SCIMClient:
117
145
  self,
118
146
  response: Response,
119
147
  expected_status_codes: List[int],
120
- expected_type: Optional[Type] = None,
148
+ expected_types: Optional[Type] = None,
149
+ check_response_payload: bool = True,
150
+ raise_scim_errors: bool = False,
121
151
  scim_ctx: Optional[Context] = None,
122
152
  ):
123
153
  if expected_status_codes and response.status_code not in expected_status_codes:
124
- raise UnexpectedStatusCode(response)
154
+ raise UnexpectedStatusCode(source=response)
125
155
 
126
156
  # Interoperability considerations: The "application/scim+json" media
127
157
  # type is intended to identify JSON structure data that conforms to
@@ -131,7 +161,7 @@ class SCIMClient:
131
161
 
132
162
  expected_response_content_types = ("application/scim+json", "application/json")
133
163
  if response.headers.get("content-type") not in expected_response_content_types:
134
- raise UnexpectedContentType(response)
164
+ raise UnexpectedContentType(source=response)
135
165
 
136
166
  # In addition to returning an HTTP response code, implementers MUST return
137
167
  # the errors in the body of the response in a JSON format
@@ -145,21 +175,45 @@ class SCIMClient:
145
175
  try:
146
176
  response_payload = response.json()
147
177
  except json.decoder.JSONDecodeError as exc:
148
- raise UnexpectedContentFormat(response) from exc
178
+ raise UnexpectedContentFormat(source=response) from exc
179
+
180
+ if not check_response_payload:
181
+ return response_payload
149
182
 
150
183
  try:
151
- return Error.model_validate(response_payload)
184
+ error = Error.model_validate(response_payload)
185
+ if raise_scim_errors:
186
+ raise SCIMResponseErrorObject(source=error)
187
+ return error
152
188
  except ValidationError:
153
189
  pass
154
190
 
155
- if expected_type:
191
+ if not expected_types:
192
+ return response_payload
193
+
194
+ actual_type = Resource.get_by_payload(
195
+ expected_types, response_payload, with_extensions=False
196
+ )
197
+
198
+ if not actual_type:
199
+ expected = ", ".join([type.__name__ for type in expected_types])
156
200
  try:
157
- return expected_type.model_validate(response_payload, scim_ctx=scim_ctx)
158
- except ValidationError as exc:
159
- exc.response_payload = response_payload
160
- raise exc
201
+ schema = ", ".join(response_payload["schemas"])
202
+ message = f"Expected type {expected} but got unknow resource with schemas: {schema}"
203
+ except KeyError:
204
+ message = (
205
+ f"Expected type {expected} but got undefined object with no schema"
206
+ )
161
207
 
162
- return response_payload
208
+ raise SCIMResponseError(message, source=response)
209
+
210
+ try:
211
+ return actual_type.model_validate(response_payload, scim_ctx=scim_ctx)
212
+ except ValidationError as exc:
213
+ scim_exc = ResponsePayloadValidationError(source=response)
214
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
215
+ scim_exc.add_note(str(exc))
216
+ raise scim_exc from exc
163
217
 
164
218
  def create(
165
219
  self,
@@ -167,6 +221,7 @@ class SCIMClient:
167
221
  check_request_payload: bool = True,
168
222
  check_response_payload: bool = True,
169
223
  check_status_code: bool = True,
224
+ raise_scim_errors: bool = False,
170
225
  **kwargs,
171
226
  ) -> Union[AnyResource, Error, Dict]:
172
227
  """Perform a POST request to create, as defined in :rfc:`RFC7644 §3.3
@@ -179,12 +234,32 @@ class SCIMClient:
179
234
  :param check_response_payload: Whether to validate that the response payload is valid.
180
235
  If set, the raw payload will be returned.
181
236
  :param check_status_code: Whether to validate that the response status code is valid.
237
+ :param raise_scim_errors: If :data:`True` and the server returned an
238
+ :class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
239
+ exception will be raised. If :data:`False` the error object is returned.
182
240
  :param kwargs: Additional parameters passed to the underlying HTTP request
183
241
  library.
184
242
 
185
243
  :return:
186
244
  - An :class:`~scim2_models.Error` object in case of error.
187
- - The created object as returned by the server in case of success.
245
+ - The created object as returned by the server in case of success and :code:`check_response_payload` is :data:`True`.
246
+ - The created object payload as returned by the server in case of success and :code:`check_response_payload` is :data:`False`.
247
+
248
+ .. code-block:: python
249
+ :caption: Creation of a `User` resource
250
+
251
+ from scim2_models import User
252
+
253
+ request = User(user_name="bjensen@example.com")
254
+ response = scim.create(request)
255
+ # 'response' may be a User or an Error object
256
+
257
+ .. tip::
258
+
259
+ Check the :attr:`~scim2_models.Context.RESOURCE_CREATION_REQUEST`
260
+ and :attr:`~scim2_models.Context.RESOURCE_CREATION_RESPONSE` contexts to understand
261
+ which value will excluded from the request payload, and which values are expected in
262
+ the response payload.
188
263
  """
189
264
 
190
265
  if not check_request_payload:
@@ -198,35 +273,50 @@ class SCIMClient:
198
273
  else:
199
274
  resource_type = Resource.get_by_payload(self.resource_types, resource)
200
275
  if not resource_type:
201
- raise SCIMClientError(
202
- None, "Cannot guess resource type from the payload"
276
+ raise SCIMRequestError(
277
+ "Cannot guess resource type from the payload"
203
278
  )
204
279
 
205
- resource = resource_type.model_validate(resource)
280
+ try:
281
+ resource = resource_type.model_validate(resource)
282
+ except ValidationError as exc:
283
+ scim_exc = RequestPayloadValidationError(source=resource)
284
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
285
+ scim_exc.add_note(str(exc))
286
+ raise scim_exc from exc
206
287
 
207
288
  self.check_resource_type(resource_type)
208
289
  url = kwargs.pop("url", self.resource_endpoint(resource_type))
209
290
  payload = resource.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST)
210
291
 
211
- response = self.client.post(url, json=payload, **kwargs)
292
+ try:
293
+ response = self.client.post(url, json=payload, **kwargs)
294
+ except RequestError as exc:
295
+ scim_exc = RequestNetworkError(source=payload)
296
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
297
+ scim_exc.add_note(str(exc))
298
+ raise scim_exc from exc
212
299
 
213
300
  return self.check_response(
214
- response,
215
- self.CREATION_RESPONSE_STATUS_CODES if check_status_code else None,
216
- resource.__class__
217
- if check_request_payload and check_response_payload
218
- else None,
301
+ response=response,
302
+ expected_status_codes=(
303
+ self.CREATION_RESPONSE_STATUS_CODES if check_status_code else None
304
+ ),
305
+ expected_types=([resource.__class__] if check_request_payload else None),
306
+ check_response_payload=check_response_payload,
307
+ raise_scim_errors=raise_scim_errors,
219
308
  scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
220
309
  )
221
310
 
222
311
  def query(
223
312
  self,
224
- resource_type: Type,
313
+ resource_type: Optional[Type] = None,
225
314
  id: Optional[str] = None,
226
315
  search_request: Optional[Union[SearchRequest, Dict]] = None,
227
316
  check_request_payload: bool = True,
228
317
  check_response_payload: bool = True,
229
318
  check_status_code: bool = True,
319
+ raise_scim_errors: bool = False,
230
320
  **kwargs,
231
321
  ) -> Union[AnyResource, ListResponse[AnyResource], Error, Dict]:
232
322
  """Perform a GET request to read resources, as defined in :rfc:`RFC7644
@@ -243,72 +333,58 @@ class SCIMClient:
243
333
  :param check_response_payload: Whether to validate that the response payload is valid.
244
334
  If set, the raw payload will be returned.
245
335
  :param check_status_code: Whether to validate that the response status code is valid.
336
+ :param raise_scim_errors: If :data:`True` and the server returned an
337
+ :class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
338
+ exception will be raised. If :data:`False` the error object is returned.
246
339
  :param kwargs: Additional parameters passed to the underlying HTTP request library.
247
340
 
248
341
  :return:
249
342
  - A :class:`~scim2_models.Error` object in case of error.
250
343
  - A `resource_type` object in case of success if `id` is not :data:`None`
251
344
  - A :class:`~scim2_models.ListResponse[resource_type]` object in case of success if `id` is :data:`None`
252
- """
253
345
 
254
- self.check_resource_type(resource_type)
255
- if not check_request_payload:
256
- payload = search_request
346
+ .. note::
257
347
 
258
- else:
259
- payload = (
260
- search_request.model_dump(
261
- exclude_unset=True,
262
- scim_ctx=Context.RESOURCE_QUERY_REQUEST,
263
- )
264
- if search_request
265
- else None
266
- )
348
+ Querying a :class:`~scim2_models.ServiceProviderConfig` will return a
349
+ single object, and not a :class:`~scim2_models.ListResponse`.
267
350
 
268
- if not id:
269
- expected_type = ListResponse[resource_type]
270
- url = self.resource_endpoint(resource_type)
351
+ :usage:
271
352
 
272
- else:
273
- expected_type = resource_type
274
- url = self.resource_endpoint(resource_type) + f"/{id}"
353
+ .. code-block:: python
354
+ :caption: Query of a `User` resource knowing its id
275
355
 
276
- response = self.client.get(url, params=payload, **kwargs)
277
- return self.check_response(
278
- response,
279
- self.QUERY_RESPONSE_STATUS_CODES if check_status_code else None,
280
- expected_type if check_response_payload else None,
281
- scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
282
- )
356
+ from scim2_models import User
283
357
 
284
- def query_all(
285
- self,
286
- search_request: Optional[SearchRequest] = None,
287
- check_request_payload: bool = True,
288
- check_response_payload: bool = True,
289
- check_status_code: bool = True,
290
- **kwargs,
291
- ) -> Union[AnyResource, ListResponse[AnyResource], Error, Dict]:
292
- """Perform a GET request to read all available resources, as defined in
293
- :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>`.
358
+ response = scim.query(User, "my-user-id)
359
+ # 'response' may be a User or an Error object
294
360
 
295
- :param search_request: An object detailing the search query parameters.
296
- :param check_request_payload: If :data:`False`,
297
- :code:`search_request` is expected to be a dict that will be passed as-is in the request.
298
- :param check_response_payload: Whether to validate that the response payload is valid.
299
- If set, the raw payload will be returned.
300
- :param check_status_code: Whether to validate that the response status code is valid.
301
- :param kwargs: Additional parameters passed to the underlying
302
- HTTP request library.
361
+ .. code-block:: python
362
+ :caption: Query of all the `User` resources filtering the ones with `userName` starts with `john`
303
363
 
304
- :return:
305
- - A :class:`~scim2_models.Error` object in case of error.
306
- - A :class:`~scim2_models.ListResponse[resource_type]` object in case of success.
364
+ from scim2_models import User, SearchRequest
365
+
366
+ req = SearchRequest(filter='userName sw "john"')
367
+ response = scim.query(User, search_request=search_request)
368
+ # 'response' may be a ListResponse[User] or an Error object
369
+
370
+ .. code-block:: python
371
+ :caption: Query of all the available resources
372
+
373
+ from scim2_models import User, SearchRequest
374
+
375
+ response = scim.query()
376
+ # 'response' may be a ListResponse[Union[User, Group, ...]] or an Error object
377
+
378
+ .. tip::
379
+
380
+ Check the :attr:`~scim2_models.Context.RESOURCE_QUERY_REQUEST`
381
+ and :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE` contexts to understand
382
+ which value will excluded from the request payload, and which values are expected in
383
+ the response payload.
307
384
  """
308
385
 
309
- # A query against a server root indicates that all resources within the
310
- # server SHALL be included, subject to filtering.
311
- # https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.4.2.1
386
+ if resource_type and check_request_payload:
387
+ self.check_resource_type(resource_type)
312
388
 
313
389
  if not check_request_payload:
314
390
  payload = search_request
@@ -316,20 +392,49 @@ class SCIMClient:
316
392
  else:
317
393
  payload = (
318
394
  search_request.model_dump(
319
- exclude_unset=True, scim_ctx=Context.RESOURCE_QUERY_REQUEST
395
+ exclude_unset=True,
396
+ scim_ctx=Context.RESOURCE_QUERY_REQUEST,
320
397
  )
321
398
  if search_request
322
399
  else None
323
400
  )
324
401
 
325
- response = self.client.get("/", params=payload)
402
+ url = kwargs.pop("url", self.resource_endpoint(resource_type))
403
+
404
+ if resource_type is None:
405
+ expected_types = [
406
+ *self.resource_types,
407
+ ListResponse[Union[self.resource_types]],
408
+ ]
409
+
410
+ elif resource_type == ServiceProviderConfig:
411
+ expected_types = [resource_type]
412
+ if id:
413
+ raise SCIMClientError("ServiceProviderConfig cannot have an id")
414
+
415
+ elif id:
416
+ expected_types = [resource_type]
417
+ url = f"{url}/{id}"
418
+
419
+ else:
420
+ expected_types = [ListResponse[resource_type]]
421
+
422
+ try:
423
+ response = self.client.get(url, params=payload, **kwargs)
424
+ except RequestError as exc:
425
+ scim_exc = RequestNetworkError(source=payload)
426
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
427
+ scim_exc.add_note(str(exc))
428
+ raise scim_exc from exc
326
429
 
327
430
  return self.check_response(
328
- response,
329
- self.QUERY_RESPONSE_STATUS_CODES if check_status_code else None,
330
- ListResponse[Union[self.resource_types]]
331
- if check_response_payload
332
- else None,
431
+ response=response,
432
+ expected_status_codes=(
433
+ self.QUERY_RESPONSE_STATUS_CODES if check_status_code else None
434
+ ),
435
+ expected_types=expected_types,
436
+ check_response_payload=check_response_payload,
437
+ raise_scim_errors=raise_scim_errors,
333
438
  scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
334
439
  )
335
440
 
@@ -339,6 +444,7 @@ class SCIMClient:
339
444
  check_request_payload: bool = True,
340
445
  check_response_payload: bool = True,
341
446
  check_status_code: bool = True,
447
+ raise_scim_errors: bool = False,
342
448
  **kwargs,
343
449
  ) -> Union[AnyResource, ListResponse[AnyResource], Error, Dict]:
344
450
  """Perform a POST search request to read all available resources, as
@@ -352,12 +458,33 @@ class SCIMClient:
352
458
  :param check_response_payload: Whether to validate that the response payload is valid.
353
459
  If set, the raw payload will be returned.
354
460
  :param check_status_code: Whether to validate that the response status code is valid.
461
+ :param raise_scim_errors: If :data:`True` and the server returned an
462
+ :class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
463
+ exception will be raised. If :data:`False` the error object is returned.
355
464
  :param kwargs: Additional parameters passed to the underlying
356
465
  HTTP request library.
357
466
 
358
467
  :return:
359
468
  - A :class:`~scim2_models.Error` object in case of error.
360
469
  - A :class:`~scim2_models.ListResponse[resource_type]` object in case of success.
470
+
471
+ :usage:
472
+
473
+ .. code-block:: python
474
+ :caption: Searching for all the resources filtering the ones with `id` contains with `admin`
475
+
476
+ from scim2_models import User, SearchRequest
477
+
478
+ req = SearchRequest(filter='id co "john"')
479
+ response = scim.search(search_request=search_request)
480
+ # 'response' may be a ListResponse[User] or an Error object
481
+
482
+ .. tip::
483
+
484
+ Check the :attr:`~scim2_models.Context.SEARCH_REQUEST`
485
+ and :attr:`~scim2_models.Context.SEARCH_RESPONSE` contexts to understand
486
+ which value will excluded from the request payload, and which values are expected in
487
+ the response payload.
361
488
  """
362
489
 
363
490
  if not check_request_payload:
@@ -372,38 +499,84 @@ class SCIMClient:
372
499
  else None
373
500
  )
374
501
 
375
- response = self.client.post("/.search", json=payload)
502
+ url = kwargs.pop("url", "/.search")
503
+
504
+ try:
505
+ response = self.client.post(url, json=payload)
506
+ except RequestError as exc:
507
+ scim_exc = RequestNetworkError(source=payload)
508
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
509
+ scim_exc.add_note(str(exc))
510
+ raise scim_exc from exc
376
511
 
377
512
  return self.check_response(
378
- response,
379
- self.SEARCH_RESPONSE_STATUS_CODES if check_status_code else None,
380
- ListResponse[Union[self.resource_types]]
381
- if check_response_payload
382
- else None,
513
+ response=response,
514
+ expected_status_codes=(
515
+ self.SEARCH_RESPONSE_STATUS_CODES if check_status_code else None
516
+ ),
517
+ expected_types=[ListResponse[Union[self.resource_types]]],
518
+ check_response_payload=check_response_payload,
519
+ raise_scim_errors=raise_scim_errors,
383
520
  scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
384
521
  )
385
522
 
386
523
  def delete(
387
- self, resource_type: Type, id: str, check_status_code: bool = True, **kwargs
524
+ self,
525
+ resource_type: Type,
526
+ id: str,
527
+ check_response_payload: bool = True,
528
+ check_status_code: bool = True,
529
+ raise_scim_errors: bool = False,
530
+ **kwargs,
388
531
  ) -> Optional[Union[Error, Dict]]:
389
532
  """Perform a DELETE request to create, as defined in :rfc:`RFC7644 §3.6
390
533
  <7644#section-3.6>`.
391
534
 
535
+ :param resource_type: The type of the resource to delete.
536
+ :param id: The type id the resource to delete.
537
+ :param check_response_payload: Whether to validate that the response payload is valid.
538
+ If set, the raw payload will be returned.
392
539
  :param check_status_code: Whether to validate that the response status code is valid.
540
+ :param raise_scim_errors: If :data:`True` and the server returned an
541
+ :class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
542
+ exception will be raised. If :data:`False` the error object is returned.
393
543
  :param kwargs: Additional parameters passed to the underlying
394
544
  HTTP request library.
395
545
 
396
546
  :return:
397
547
  - A :class:`~scim2_models.Error` object in case of error.
398
548
  - :data:`None` in case of success.
549
+
550
+ :usage:
551
+
552
+ .. code-block:: python
553
+ :caption: Deleting an `User` which `id` is `foobar`
554
+
555
+ from scim2_models import User, SearchRequest
556
+
557
+ response = scim.delete(User, "foobar")
558
+ # 'response' may be None, or an Error object
399
559
  """
400
560
 
401
561
  self.check_resource_type(resource_type)
402
- url = self.resource_endpoint(resource_type) + f"/{id}"
403
- response = self.client.delete(url, **kwargs)
562
+ delete_url = self.resource_endpoint(resource_type) + f"/{id}"
563
+ url = kwargs.pop("url", delete_url)
564
+
565
+ try:
566
+ response = self.client.delete(url, **kwargs)
567
+ except RequestError as exc:
568
+ scim_exc = RequestNetworkError()
569
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
570
+ scim_exc.add_note(str(exc))
571
+ raise scim_exc from exc
404
572
 
405
573
  return self.check_response(
406
- response, self.DELETION_RESPONSE_STATUS_CODES if check_status_code else None
574
+ response=response,
575
+ expected_status_codes=(
576
+ self.DELETION_RESPONSE_STATUS_CODES if check_status_code else None
577
+ ),
578
+ check_response_payload=check_response_payload,
579
+ raise_scim_errors=raise_scim_errors,
407
580
  )
408
581
 
409
582
  def replace(
@@ -412,6 +585,7 @@ class SCIMClient:
412
585
  check_request_payload: bool = True,
413
586
  check_response_payload: bool = True,
414
587
  check_status_code: bool = True,
588
+ raise_scim_errors: bool = False,
415
589
  **kwargs,
416
590
  ) -> Union[AnyResource, Error, Dict]:
417
591
  """Perform a PUT request to replace a resource, as defined in
@@ -424,12 +598,34 @@ class SCIMClient:
424
598
  :param check_response_payload: Whether to validate that the response payload is valid.
425
599
  If set, the raw payload will be returned.
426
600
  :param check_status_code: Whether to validate that the response status code is valid.
601
+ :param raise_scim_errors: If :data:`True` and the server returned an
602
+ :class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
603
+ exception will be raised. If :data:`False` the error object is returned.
427
604
  :param kwargs: Additional parameters passed to the underlying
428
605
  HTTP request library.
429
606
 
430
607
  :return:
431
608
  - An :class:`~scim2_models.Error` object in case of error.
432
609
  - The updated object as returned by the server in case of success.
610
+
611
+ :usage:
612
+
613
+ .. code-block:: python
614
+ :caption: Replacement of a `User` resource
615
+
616
+ from scim2_models import User
617
+
618
+ user = scim.query(User, "my-used-id")
619
+ user.display_name = "Fancy New Name"
620
+ response = scim.update(user)
621
+ # 'response' may be a User or an Error object
622
+
623
+ .. tip::
624
+
625
+ Check the :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`
626
+ and :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_RESPONSE` contexts to understand
627
+ which value will excluded from the request payload, and which values are expected in
628
+ the response payload.
433
629
  """
434
630
 
435
631
  if not check_request_payload:
@@ -443,30 +639,45 @@ class SCIMClient:
443
639
  else:
444
640
  resource_type = Resource.get_by_payload(self.resource_types, resource)
445
641
  if not resource_type:
446
- raise SCIMClientError(
447
- None, "Cannot guess resource type from the payload"
642
+ raise SCIMRequestError(
643
+ "Cannot guess resource type from the payload",
644
+ source=resource,
448
645
  )
449
646
 
450
- resource = resource_type.model_validate(resource)
647
+ try:
648
+ resource = resource_type.model_validate(resource)
649
+ except ValidationError as exc:
650
+ scim_exc = RequestPayloadValidationError(source=resource)
651
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
652
+ scim_exc.add_note(str(exc))
653
+ raise scim_exc from exc
451
654
 
452
655
  self.check_resource_type(resource_type)
453
656
 
454
657
  if not resource.id:
455
- raise SCIMClientError(None, "Resource must have an id")
658
+ raise SCIMRequestError("Resource must have an id", source=resource)
456
659
 
457
660
  payload = resource.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST)
458
661
  url = kwargs.pop(
459
662
  "url", self.resource_endpoint(resource.__class__) + f"/{resource.id}"
460
663
  )
461
664
 
462
- response = self.client.put(url, json=payload, **kwargs)
665
+ try:
666
+ response = self.client.put(url, json=payload, **kwargs)
667
+ except RequestError as exc:
668
+ scim_exc = RequestNetworkError(source=payload)
669
+ if hasattr(scim_exc, "add_note"): # pragma: no cover
670
+ scim_exc.add_note(str(exc))
671
+ raise scim_exc from exc
463
672
 
464
673
  return self.check_response(
465
- response,
466
- self.REPLACEMENT_RESPONSE_STATUS_CODES if check_status_code else None,
467
- resource.__class__
468
- if check_request_payload and check_response_payload
469
- else None,
674
+ response=response,
675
+ expected_status_codes=(
676
+ self.REPLACEMENT_RESPONSE_STATUS_CODES if check_status_code else None
677
+ ),
678
+ expected_types=([resource.__class__] if check_request_payload else None),
679
+ check_response_payload=check_response_payload,
680
+ raise_scim_errors=raise_scim_errors,
470
681
  scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
471
682
  )
472
683
 
scim2_client/errors.py CHANGED
@@ -1,43 +1,125 @@
1
- from httpx import Response
1
+ from typing import Any
2
2
 
3
3
 
4
4
  class SCIMClientError(Exception):
5
- def __init__(self, response: Response, *args, **kwargs):
6
- self.response = response
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
7
15
  super().__init__(*args, **kwargs)
8
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."""
9
61
 
10
- class UnexpectedStatusCode(SCIMClientError):
11
- def __init__(
12
- self,
13
- response: Response,
14
- *args,
15
- **kwargs,
16
- ):
62
+
63
+ class SCIMResponseErrorObject(SCIMResponseError):
64
+ """The server response returned a :class:`scim2_models.Error` object.
65
+
66
+ Those errors are only raised when the :code:`raise_scim_errors` parameter is :data:`True`.
67
+ """
68
+
69
+ def __init__(self, *args, **kwargs):
70
+ message = kwargs.pop(
71
+ "message",
72
+ f"The server returned a SCIM Error object: {kwargs['source'].detail}",
73
+ )
74
+ super().__init__(message, *args, **kwargs)
75
+
76
+
77
+ class UnexpectedStatusCode(SCIMResponseError):
78
+ """Error raised when a server returned an unexpected status code for a
79
+ given :class:`~scim2_models.Context`."""
80
+
81
+ def __init__(self, *args, **kwargs):
17
82
  message = kwargs.pop(
18
- "message", f"Unexpected response status code: {response.status_code}"
83
+ "message",
84
+ f"Unexpected response status code: {kwargs['source'].status_code}",
19
85
  )
20
- super().__init__(response, message, *args, **kwargs)
86
+ super().__init__(message, *args, **kwargs)
21
87
 
22
88
 
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", "")
89
+ class UnexpectedContentType(SCIMResponseError):
90
+ """Error raised when a server returned an unexpected `Content-Type` header
91
+ in a response."""
92
+
93
+ def __init__(self, *args, **kwargs):
94
+ content_type = kwargs["source"].headers.get("content-type", "")
31
95
  message = kwargs.pop("message", f"Unexpected content type: {content_type}")
32
- super().__init__(response, message, *args, **kwargs)
96
+ super().__init__(message, *args, **kwargs)
97
+
33
98
 
99
+ class UnexpectedContentFormat(SCIMResponseError):
100
+ """Error raised when a server returned a response in a non-JSON format."""
34
101
 
35
- class UnexpectedContentFormat(SCIMClientError):
36
- def __init__(
37
- self,
38
- response: Response,
39
- *args,
40
- **kwargs,
41
- ):
102
+ def __init__(self, *args, **kwargs):
42
103
  message = kwargs.pop("message", "Unexpected response content format")
43
- super().__init__(response, message, *args, **kwargs)
104
+ super().__init__(message, *args, **kwargs)
105
+
106
+
107
+ class ResponsePayloadValidationError(SCIMResponseError):
108
+ """Error raised when the server returned a payload that cannot be
109
+ validated.
110
+
111
+ This error is raised when a :class:`pydantic.ValidationError` has been catched
112
+ while validating the server response payload.
113
+ The original :class:`~pydantic.ValidationError` is available with :attr:`~BaseException.__cause__`.
114
+
115
+ .. code-block:: python
116
+
117
+ try:
118
+ scim.query(User, "foobar")
119
+ except ResponsePayloadValidationError as exc:
120
+ print("Original validation error cause", exc.__cause__)
121
+ """
122
+
123
+ def __init__(self, *args, **kwargs):
124
+ message = kwargs.pop("message", "Server response payload validation error")
125
+ super().__init__(message, *args, **kwargs)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: scim2-client
3
- Version: 0.1.4
3
+ Version: 0.1.6
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
@@ -71,3 +81,8 @@ assert isinstance(response, Error)
71
81
  assert response.detail == "One or more of the attribute values are already in use or are reserved."
72
82
  ```
73
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
+
@@ -0,0 +1,6 @@
1
+ scim2_client/__init__.py,sha256=vgrTJZIqJLZc72XCJwYKrSCGUDkMZTJjCJiZkHERZGs,780
2
+ scim2_client/client.py,sha256=_nGGGaKhwQSgJU1b56C2O859v-YC4YNDfm3uX5U0c8U,26975
3
+ scim2_client/errors.py,sha256=Lu_4lPlbryqWYGLPmH2iWaVmlay8S4PrH6lUPIvcisA,4430
4
+ scim2_client-0.1.6.dist-info/METADATA,sha256=DCymPPTKmd_PXLRfzblUqgBajOs7N8ZqSrNoANtcyQg,3495
5
+ scim2_client-0.1.6.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
6
+ scim2_client-0.1.6.dist-info/RECORD,,
@@ -1,6 +0,0 @@
1
- scim2_client/__init__.py,sha256=2UNsl6HNtVUv5LVcnmLaCHyT4SqAUUFOIWW2r5XGv6A,338
2
- scim2_client/client.py,sha256=HzsOyb4xqUC05-HuhV18g5abYK-00AY0iCni4-XBEtA,17880
3
- scim2_client/errors.py,sha256=uOOAwsD8rDrC8BQbwPid051YyWtexTcS8b7i6QC6-CM,1175
4
- scim2_client-0.1.4.dist-info/METADATA,sha256=khpZeya8xou5elKL0lN9VUC2sMS6KgYFyL690kH7GfA,2662
5
- scim2_client-0.1.4.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
6
- scim2_client-0.1.4.dist-info/RECORD,,