scim2-client 0.2.2__py3-none-any.whl → 0.3.0__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/client.py CHANGED
@@ -1,12 +1,8 @@
1
- import json
2
- import json.decoder
3
1
  import sys
2
+ from dataclasses import dataclass
4
3
  from typing import Optional
5
4
  from typing import Union
6
5
 
7
- from httpx import Client
8
- from httpx import RequestError
9
- from httpx import Response
10
6
  from pydantic import ValidationError
11
7
  from scim2_models import AnyResource
12
8
  from scim2_models import Context
@@ -19,16 +15,14 @@ from scim2_models import Schema
19
15
  from scim2_models import SearchRequest
20
16
  from scim2_models import ServiceProviderConfig
21
17
 
22
- from .errors import RequestNetworkError
23
- from .errors import RequestPayloadValidationError
24
- from .errors import ResponsePayloadValidationError
25
- from .errors import SCIMClientError
26
- from .errors import SCIMRequestError
27
- from .errors import SCIMResponseError
28
- from .errors import SCIMResponseErrorObject
29
- from .errors import UnexpectedContentFormat
30
- from .errors import UnexpectedContentType
31
- from .errors import UnexpectedStatusCode
18
+ from scim2_client.errors import RequestPayloadValidationError
19
+ from scim2_client.errors import ResponsePayloadValidationError
20
+ from scim2_client.errors import SCIMClientError
21
+ from scim2_client.errors import SCIMRequestError
22
+ from scim2_client.errors import SCIMResponseError
23
+ from scim2_client.errors import SCIMResponseErrorObject
24
+ from scim2_client.errors import UnexpectedContentType
25
+ from scim2_client.errors import UnexpectedStatusCode
32
26
 
33
27
  BASE_HEADERS = {
34
28
  "Accept": "application/scim+json",
@@ -36,11 +30,23 @@ BASE_HEADERS = {
36
30
  }
37
31
 
38
32
 
39
- class SCIMClient:
40
- """An object that perform SCIM requests and validate responses.
33
+ @dataclass
34
+ class RequestPayload:
35
+ request_kwargs: dict
36
+ url: Optional[str] = None
37
+ payload: Optional[dict] = None
38
+ expected_types: Optional[list[type[Resource]]] = None
39
+ expected_status_codes: Optional[list[int]] = None
41
40
 
42
- :param client: A :class:`httpx.Client` instance that will be used to send requests.
43
- :param resource_types: A tuple of :class:`~scim2_models.Resource` types expected to be handled by the SCIMClient.
41
+
42
+ class BaseSCIMClient:
43
+ """The base model for request clients.
44
+
45
+ It goal is to parse the requests and responses and check if they comply with the SCIM specifications.
46
+
47
+ This class can be inherited and used as a basis for request engine integration.
48
+
49
+ :param resource_models: A tuple of :class:`~scim2_models.Resource` types expected to be handled by the SCIM client.
44
50
  If a request payload describe a resource that is not in this list, an exception will be raised.
45
51
 
46
52
  .. note::
@@ -128,44 +134,47 @@ class SCIMClient:
128
134
  :rfc:`RFC7644 §3.12 <7644#section-3.12>`.
129
135
  """
130
136
 
131
- def __init__(
132
- self, client: Client, resource_types: Optional[tuple[type[Resource]]] = None
133
- ):
134
- self.client = client
135
- self.resource_types = tuple(
136
- set(resource_types or []) | {ResourceType, Schema, ServiceProviderConfig}
137
+ def __init__(self, resource_models: Optional[tuple[type[Resource]]] = None):
138
+ self.resource_models = tuple(
139
+ set(resource_models or []) | {ResourceType, Schema, ServiceProviderConfig}
137
140
  )
138
141
 
139
- def check_resource_type(self, resource_type: type[Resource]) -> None:
140
- if resource_type not in self.resource_types:
141
- raise SCIMRequestError(f"Unknown resource type: '{resource_type}'")
142
+ def check_resource_model(
143
+ self, resource_model: type[Resource], payload=None
144
+ ) -> None:
145
+ if resource_model not in self.resource_models:
146
+ raise SCIMRequestError(
147
+ f"Unknown resource type: '{resource_model}'", source=payload
148
+ )
142
149
 
143
- def resource_endpoint(self, resource_type: Optional[type[Resource]]) -> str:
144
- if resource_type is None:
150
+ def resource_endpoint(self, resource_model: Optional[type[Resource]]) -> str:
151
+ if resource_model is None:
145
152
  return "/"
146
153
 
147
154
  # This one takes no final 's'
148
- if resource_type is ServiceProviderConfig:
155
+ if resource_model is ServiceProviderConfig:
149
156
  return "/ServiceProviderConfig"
150
157
 
151
158
  try:
152
- first_bracket_index = resource_type.__name__.index("[")
153
- root_name = resource_type.__name__[:first_bracket_index]
159
+ first_bracket_index = resource_model.__name__.index("[")
160
+ root_name = resource_model.__name__[:first_bracket_index]
154
161
  except ValueError:
155
- root_name = resource_type.__name__
162
+ root_name = resource_model.__name__
156
163
  return f"/{root_name}s"
157
164
 
158
165
  def check_response(
159
166
  self,
160
- response: Response,
167
+ payload: Optional[dict],
168
+ status_code: int,
169
+ headers: dict,
161
170
  expected_status_codes: Optional[list[int]] = None,
162
171
  expected_types: Optional[list[type[Resource]]] = None,
163
172
  check_response_payload: bool = True,
164
173
  raise_scim_errors: bool = True,
165
174
  scim_ctx: Optional[Context] = None,
166
175
  ) -> Union[Error, None, dict, type[Resource]]:
167
- if expected_status_codes and response.status_code not in expected_status_codes:
168
- raise UnexpectedStatusCode(source=response)
176
+ if expected_status_codes and status_code not in expected_status_codes:
177
+ raise UnexpectedStatusCode()
169
178
 
170
179
  # Interoperability considerations: The "application/scim+json" media
171
180
  # type is intended to identify JSON structure data that conforms to
@@ -173,24 +182,21 @@ class SCIMClient:
173
182
  # SCIM are known to informally use "application/json".
174
183
  # https://datatracker.ietf.org/doc/html/rfc7644.html#section-8.1
175
184
 
176
- actual_content_type = response.headers.get("content-type", "").split(";").pop(0)
185
+ actual_content_type = headers.get("content-type", "").split(";").pop(0)
177
186
  expected_response_content_types = ("application/scim+json", "application/json")
178
187
  if actual_content_type not in expected_response_content_types:
179
- raise UnexpectedContentType(source=response)
188
+ raise UnexpectedContentType(content_type=actual_content_type)
180
189
 
181
190
  # In addition to returning an HTTP response code, implementers MUST return
182
191
  # the errors in the body of the response in a JSON format
183
192
  # https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
184
193
 
185
194
  no_content_status_codes = [204, 205]
186
- if response.status_code in no_content_status_codes:
195
+ if status_code in no_content_status_codes:
187
196
  response_payload = None
188
197
 
189
198
  else:
190
- try:
191
- response_payload = response.json()
192
- except json.decoder.JSONDecodeError as exc:
193
- raise UnexpectedContentFormat(source=response) from exc
199
+ response_payload = payload
194
200
 
195
201
  if not check_response_payload:
196
202
  return response_payload
@@ -221,22 +227,233 @@ class SCIMClient:
221
227
  f"Expected type {expected} but got undefined object with no schema"
222
228
  )
223
229
 
224
- raise SCIMResponseError(message, source=response)
230
+ raise SCIMResponseError(message)
225
231
 
226
232
  try:
227
233
  return actual_type.model_validate(response_payload, scim_ctx=scim_ctx)
228
234
  except ValidationError as exc:
229
- scim_exc = ResponsePayloadValidationError(source=response)
235
+ scim_exc = ResponsePayloadValidationError()
230
236
  if sys.version_info >= (3, 11): # pragma: no cover
231
237
  scim_exc.add_note(str(exc))
232
238
  raise scim_exc from exc
233
239
 
240
+ def prepare_create_request(
241
+ self,
242
+ resource: Union[AnyResource, dict],
243
+ check_request_payload: bool = True,
244
+ expected_status_codes: Optional[list[int]] = None,
245
+ raise_scim_errors: bool = True,
246
+ **kwargs,
247
+ ) -> RequestPayload:
248
+ req = RequestPayload(
249
+ expected_status_codes=expected_status_codes,
250
+ request_kwargs=kwargs,
251
+ )
252
+
253
+ if not check_request_payload:
254
+ req.payload = resource
255
+ req.url = req.request_kwargs.pop("url", None)
256
+
257
+ else:
258
+ if isinstance(resource, Resource):
259
+ resource_model = resource.__class__
260
+
261
+ else:
262
+ resource_model = Resource.get_by_payload(self.resource_models, resource)
263
+ if not resource_model:
264
+ raise SCIMRequestError(
265
+ "Cannot guess resource type from the payload"
266
+ )
267
+
268
+ try:
269
+ resource = resource_model.model_validate(resource)
270
+ except ValidationError as exc:
271
+ scim_validation_exc = RequestPayloadValidationError(source=resource)
272
+ if sys.version_info >= (3, 11): # pragma: no cover
273
+ scim_validation_exc.add_note(str(exc))
274
+ raise scim_validation_exc from exc
275
+
276
+ self.check_resource_model(resource_model, resource)
277
+ req.expected_types = [resource.__class__]
278
+ req.url = req.request_kwargs.pop(
279
+ "url", self.resource_endpoint(resource_model)
280
+ )
281
+ req.payload = resource.model_dump(
282
+ scim_ctx=Context.RESOURCE_CREATION_REQUEST
283
+ )
284
+
285
+ return req
286
+
287
+ def prepare_query_request(
288
+ self,
289
+ resource_model: Optional[type[Resource]] = None,
290
+ id: Optional[str] = None,
291
+ search_request: Optional[Union[SearchRequest, dict]] = None,
292
+ check_request_payload: bool = True,
293
+ expected_status_codes: Optional[list[int]] = None,
294
+ raise_scim_errors: bool = True,
295
+ **kwargs,
296
+ ) -> RequestPayload:
297
+ req = RequestPayload(
298
+ expected_status_codes=expected_status_codes,
299
+ request_kwargs=kwargs,
300
+ )
301
+
302
+ if resource_model and check_request_payload:
303
+ self.check_resource_model(resource_model)
304
+
305
+ payload: Optional[SearchRequest]
306
+ if not check_request_payload:
307
+ payload = search_request
308
+
309
+ elif isinstance(search_request, SearchRequest):
310
+ payload = search_request.model_dump(
311
+ exclude_unset=True,
312
+ scim_ctx=Context.RESOURCE_QUERY_REQUEST,
313
+ )
314
+
315
+ else:
316
+ payload = None
317
+
318
+ req.payload = payload
319
+ req.url = req.request_kwargs.pop("url", self.resource_endpoint(resource_model))
320
+
321
+ if resource_model is None:
322
+ req.expected_types = [
323
+ *self.resource_models,
324
+ ListResponse[Union[self.resource_models]],
325
+ ]
326
+
327
+ elif resource_model == ServiceProviderConfig:
328
+ req.expected_types = [resource_model]
329
+ if id:
330
+ raise SCIMClientError("ServiceProviderConfig cannot have an id")
331
+
332
+ elif id:
333
+ req.expected_types = [resource_model]
334
+ req.url = f"{req.url}/{id}"
335
+
336
+ else:
337
+ req.expected_types = [ListResponse[resource_model]]
338
+
339
+ return req
340
+
341
+ def prepare_search_request(
342
+ self,
343
+ search_request: Optional[SearchRequest] = None,
344
+ check_request_payload: bool = True,
345
+ expected_status_codes: Optional[list[int]] = None,
346
+ raise_scim_errors: bool = True,
347
+ **kwargs,
348
+ ) -> RequestPayload:
349
+ req = RequestPayload(
350
+ expected_status_codes=expected_status_codes,
351
+ request_kwargs=kwargs,
352
+ )
353
+
354
+ if not check_request_payload:
355
+ req.payload = search_request
356
+
357
+ else:
358
+ req.payload = (
359
+ search_request.model_dump(
360
+ exclude_unset=True, scim_ctx=Context.RESOURCE_QUERY_RESPONSE
361
+ )
362
+ if search_request
363
+ else None
364
+ )
365
+
366
+ req.url = req.request_kwargs.pop("url", "/.search")
367
+ req.expected_types = [ListResponse[Union[self.resource_models]]]
368
+ return req
369
+
370
+ def prepare_delete_request(
371
+ self,
372
+ resource_model: type,
373
+ id: str,
374
+ expected_status_codes: Optional[list[int]] = None,
375
+ raise_scim_errors: bool = True,
376
+ **kwargs,
377
+ ) -> RequestPayload:
378
+ req = RequestPayload(
379
+ expected_status_codes=expected_status_codes,
380
+ request_kwargs=kwargs,
381
+ )
382
+
383
+ self.check_resource_model(resource_model)
384
+ delete_url = self.resource_endpoint(resource_model) + f"/{id}"
385
+ req.url = req.request_kwargs.pop("url", delete_url)
386
+ return req
387
+
388
+ def prepare_replace_request(
389
+ self,
390
+ resource: Union[AnyResource, dict],
391
+ check_request_payload: bool = True,
392
+ expected_status_codes: Optional[list[int]] = None,
393
+ raise_scim_errors: bool = True,
394
+ **kwargs,
395
+ ) -> RequestPayload:
396
+ req = RequestPayload(
397
+ expected_status_codes=expected_status_codes,
398
+ request_kwargs=kwargs,
399
+ )
400
+
401
+ if not check_request_payload:
402
+ req.payload = resource
403
+ req.url = kwargs.pop("url", None)
404
+
405
+ else:
406
+ if isinstance(resource, Resource):
407
+ resource_model = resource.__class__
408
+
409
+ else:
410
+ resource_model = Resource.get_by_payload(self.resource_models, resource)
411
+ if not resource_model:
412
+ raise SCIMRequestError(
413
+ "Cannot guess resource type from the payload",
414
+ source=resource,
415
+ )
416
+
417
+ try:
418
+ resource = resource_model.model_validate(resource)
419
+ except ValidationError as exc:
420
+ scim_validation_exc = RequestPayloadValidationError(source=resource)
421
+ if sys.version_info >= (3, 11): # pragma: no cover
422
+ scim_validation_exc.add_note(str(exc))
423
+ raise scim_validation_exc from exc
424
+
425
+ self.check_resource_model(resource_model, resource)
426
+
427
+ if not resource.id:
428
+ raise SCIMRequestError("Resource must have an id", source=resource)
429
+
430
+ req.expected_types = [resource.__class__]
431
+ req.payload = resource.model_dump(
432
+ scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST
433
+ )
434
+ req.url = req.request_kwargs.pop(
435
+ "url", self.resource_endpoint(resource.__class__) + f"/{resource.id}"
436
+ )
437
+
438
+ return req
439
+
440
+ def modify(
441
+ self, resource: Union[AnyResource, dict], op: PatchOp, **kwargs
442
+ ) -> Optional[Union[AnyResource, dict]]:
443
+ raise NotImplementedError()
444
+
445
+
446
+ class BaseSyncSCIMClient(BaseSCIMClient):
447
+ """Base class for synchronous request clients."""
448
+
234
449
  def create(
235
450
  self,
236
451
  resource: Union[AnyResource, dict],
237
452
  check_request_payload: bool = True,
238
453
  check_response_payload: bool = True,
239
- expected_status_codes: Optional[list[int]] = CREATION_RESPONSE_STATUS_CODES,
454
+ expected_status_codes: Optional[
455
+ list[int]
456
+ ] = BaseSCIMClient.CREATION_RESPONSE_STATUS_CODES,
240
457
  raise_scim_errors: bool = True,
241
458
  **kwargs,
242
459
  ) -> Union[AnyResource, Error, dict]:
@@ -277,58 +494,18 @@ class SCIMClient:
277
494
  which value will excluded from the request payload, and which values are expected in
278
495
  the response payload.
279
496
  """
280
- if not check_request_payload:
281
- payload = resource
282
- url = kwargs.pop("url", None)
283
-
284
- else:
285
- if isinstance(resource, Resource):
286
- resource_type = resource.__class__
287
-
288
- else:
289
- resource_type = Resource.get_by_payload(self.resource_types, resource)
290
- if not resource_type:
291
- raise SCIMRequestError(
292
- "Cannot guess resource type from the payload"
293
- )
294
-
295
- try:
296
- resource = resource_type.model_validate(resource)
297
- except ValidationError as exc:
298
- scim_validation_exc = RequestPayloadValidationError(source=resource)
299
- if sys.version_info >= (3, 11): # pragma: no cover
300
- scim_validation_exc.add_note(str(exc))
301
- raise scim_validation_exc from exc
302
-
303
- self.check_resource_type(resource_type)
304
- url = kwargs.pop("url", self.resource_endpoint(resource_type))
305
- payload = resource.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST)
306
-
307
- try:
308
- response = self.client.post(url, json=payload, **kwargs)
309
- except RequestError as exc:
310
- scim_network_exc = RequestNetworkError(source=payload)
311
- if sys.version_info >= (3, 11): # pragma: no cover
312
- scim_network_exc.add_note(str(exc))
313
- raise scim_network_exc from exc
314
-
315
- return self.check_response(
316
- response=response,
317
- expected_status_codes=expected_status_codes,
318
- expected_types=([resource.__class__] if check_request_payload else None),
319
- check_response_payload=check_response_payload,
320
- raise_scim_errors=raise_scim_errors,
321
- scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
322
- )
497
+ raise NotImplementedError()
323
498
 
324
499
  def query(
325
500
  self,
326
- resource_type: Optional[type[Resource]] = None,
501
+ resource_model: Optional[type[Resource]] = None,
327
502
  id: Optional[str] = None,
328
503
  search_request: Optional[Union[SearchRequest, dict]] = None,
329
504
  check_request_payload: bool = True,
330
505
  check_response_payload: bool = True,
331
- expected_status_codes: Optional[list[int]] = QUERY_RESPONSE_STATUS_CODES,
506
+ expected_status_codes: Optional[
507
+ list[int]
508
+ ] = BaseSCIMClient.QUERY_RESPONSE_STATUS_CODES,
332
509
  raise_scim_errors: bool = True,
333
510
  **kwargs,
334
511
  ) -> Union[AnyResource, ListResponse[AnyResource], Error, dict]:
@@ -337,7 +514,7 @@ class SCIMClient:
337
514
  - If `id` is not :data:`None`, the resource with the exact id will be reached.
338
515
  - If `id` is :data:`None`, all the resources with the given type will be reached.
339
516
 
340
- :param resource_type: A :class:`~scim2_models.Resource` subtype or :data:`None`
517
+ :param resource_model: A :class:`~scim2_models.Resource` subtype or :data:`None`
341
518
  :param id: The SCIM id of an object to get, or :data:`None`
342
519
  :param search_request: An object detailing the search query parameters.
343
520
  :param check_request_payload: If :data:`False`,
@@ -353,8 +530,8 @@ class SCIMClient:
353
530
 
354
531
  :return:
355
532
  - A :class:`~scim2_models.Error` object in case of error.
356
- - A `resource_type` object in case of success if `id` is not :data:`None`
357
- - A :class:`~scim2_models.ListResponse[resource_type]` object in case of success if `id` is :data:`None`
533
+ - A `resource_model` object in case of success if `id` is not :data:`None`
534
+ - A :class:`~scim2_models.ListResponse[resource_model]` object in case of success if `id` is :data:`None`
358
535
 
359
536
  .. note::
360
537
 
@@ -395,71 +572,22 @@ class SCIMClient:
395
572
  which value will excluded from the request payload, and which values are expected in
396
573
  the response payload.
397
574
  """
398
- if resource_type and check_request_payload:
399
- self.check_resource_type(resource_type)
400
-
401
- payload: Optional[SearchRequest]
402
- if not check_request_payload:
403
- payload = search_request
404
-
405
- elif isinstance(search_request, SearchRequest):
406
- payload = search_request.model_dump(
407
- exclude_unset=True,
408
- scim_ctx=Context.RESOURCE_QUERY_REQUEST,
409
- )
410
-
411
- else:
412
- payload = None
413
-
414
- url = kwargs.pop("url", self.resource_endpoint(resource_type))
415
-
416
- if resource_type is None:
417
- expected_types = [
418
- *self.resource_types,
419
- ListResponse[Union[self.resource_types]],
420
- ]
421
-
422
- elif resource_type == ServiceProviderConfig:
423
- expected_types = [resource_type]
424
- if id:
425
- raise SCIMClientError("ServiceProviderConfig cannot have an id")
426
-
427
- elif id:
428
- expected_types = [resource_type]
429
- url = f"{url}/{id}"
430
-
431
- else:
432
- expected_types = [ListResponse[resource_type]]
433
-
434
- try:
435
- response = self.client.get(url, params=payload, **kwargs)
436
- except RequestError as exc:
437
- scim_exc = RequestNetworkError(source=payload)
438
- if sys.version_info >= (3, 11): # pragma: no cover
439
- scim_exc.add_note(str(exc))
440
- raise scim_exc from exc
441
-
442
- return self.check_response(
443
- response=response,
444
- expected_status_codes=expected_status_codes,
445
- expected_types=expected_types,
446
- check_response_payload=check_response_payload,
447
- raise_scim_errors=raise_scim_errors,
448
- scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
449
- )
575
+ raise NotImplementedError()
450
576
 
451
577
  def search(
452
578
  self,
453
579
  search_request: Optional[SearchRequest] = None,
454
580
  check_request_payload: bool = True,
455
581
  check_response_payload: bool = True,
456
- expected_status_codes: Optional[list[int]] = SEARCH_RESPONSE_STATUS_CODES,
582
+ expected_status_codes: Optional[
583
+ list[int]
584
+ ] = BaseSCIMClient.SEARCH_RESPONSE_STATUS_CODES,
457
585
  raise_scim_errors: bool = True,
458
586
  **kwargs,
459
587
  ) -> Union[AnyResource, ListResponse[AnyResource], Error, dict]:
460
588
  """Perform a POST search request to read all available resources, as defined in :rfc:`RFC7644 §3.4.3 <7644#section-3.4.3>`.
461
589
 
462
- :param resource_types: Resource type or union of types expected
590
+ :param resource_models: Resource type or union of types expected
463
591
  to be read from the response.
464
592
  :param search_request: An object detailing the search query parameters.
465
593
  :param check_request_payload: If :data:`False`,
@@ -476,7 +604,7 @@ class SCIMClient:
476
604
 
477
605
  :return:
478
606
  - A :class:`~scim2_models.Error` object in case of error.
479
- - A :class:`~scim2_models.ListResponse[resource_type]` object in case of success.
607
+ - A :class:`~scim2_models.ListResponse[resource_model]` object in case of success.
480
608
 
481
609
  :usage:
482
610
 
@@ -496,49 +624,22 @@ class SCIMClient:
496
624
  which value will excluded from the request payload, and which values are expected in
497
625
  the response payload.
498
626
  """
499
- if not check_request_payload:
500
- payload = search_request
501
-
502
- else:
503
- payload = (
504
- search_request.model_dump(
505
- exclude_unset=True, scim_ctx=Context.RESOURCE_QUERY_RESPONSE
506
- )
507
- if search_request
508
- else None
509
- )
510
-
511
- url = kwargs.pop("url", "/.search")
512
-
513
- try:
514
- response = self.client.post(url, json=payload)
515
- except RequestError as exc:
516
- scim_exc = RequestNetworkError(source=payload)
517
- if sys.version_info >= (3, 11): # pragma: no cover
518
- scim_exc.add_note(str(exc))
519
- raise scim_exc from exc
520
-
521
- return self.check_response(
522
- response=response,
523
- expected_status_codes=expected_status_codes,
524
- expected_types=[ListResponse[Union[self.resource_types]]],
525
- check_response_payload=check_response_payload,
526
- raise_scim_errors=raise_scim_errors,
527
- scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
528
- )
627
+ raise NotImplementedError()
529
628
 
530
629
  def delete(
531
630
  self,
532
- resource_type: type,
631
+ resource_model: type,
533
632
  id: str,
534
633
  check_response_payload: bool = True,
535
- expected_status_codes: Optional[list[int]] = DELETION_RESPONSE_STATUS_CODES,
634
+ expected_status_codes: Optional[
635
+ list[int]
636
+ ] = BaseSCIMClient.DELETION_RESPONSE_STATUS_CODES,
536
637
  raise_scim_errors: bool = True,
537
638
  **kwargs,
538
639
  ) -> Optional[Union[Error, dict]]:
539
640
  """Perform a DELETE request to create, as defined in :rfc:`RFC7644 §3.6 <7644#section-3.6>`.
540
641
 
541
- :param resource_type: The type of the resource to delete.
642
+ :param resource_model: The type of the resource to delete.
542
643
  :param id: The type id the resource to delete.
543
644
  :param check_response_payload: Whether to validate that the response payload is valid.
544
645
  If set, the raw payload will be returned.
@@ -564,31 +665,16 @@ class SCIMClient:
564
665
  response = scim.delete(User, "foobar")
565
666
  # 'response' may be None, or an Error object
566
667
  """
567
- self.check_resource_type(resource_type)
568
- delete_url = self.resource_endpoint(resource_type) + f"/{id}"
569
- url = kwargs.pop("url", delete_url)
570
-
571
- try:
572
- response = self.client.delete(url, **kwargs)
573
- except RequestError as exc:
574
- scim_exc = RequestNetworkError()
575
- if sys.version_info >= (3, 11): # pragma: no cover
576
- scim_exc.add_note(str(exc))
577
- raise scim_exc from exc
578
-
579
- return self.check_response(
580
- response=response,
581
- expected_status_codes=expected_status_codes,
582
- check_response_payload=check_response_payload,
583
- raise_scim_errors=raise_scim_errors,
584
- )
668
+ raise NotImplementedError()
585
669
 
586
670
  def replace(
587
671
  self,
588
672
  resource: Union[AnyResource, dict],
589
673
  check_request_payload: bool = True,
590
674
  check_response_payload: bool = True,
591
- expected_status_codes: Optional[list[int]] = REPLACEMENT_RESPONSE_STATUS_CODES,
675
+ expected_status_codes: Optional[
676
+ list[int]
677
+ ] = BaseSCIMClient.REPLACEMENT_RESPONSE_STATUS_CODES,
592
678
  raise_scim_errors: bool = True,
593
679
  **kwargs,
594
680
  ) -> Union[AnyResource, Error, dict]:
@@ -630,58 +716,280 @@ class SCIMClient:
630
716
  which value will excluded from the request payload, and which values are expected in
631
717
  the response payload.
632
718
  """
633
- if not check_request_payload:
634
- payload = resource
635
- url = kwargs.pop("url", None)
719
+ raise NotImplementedError()
636
720
 
637
- else:
638
- if isinstance(resource, Resource):
639
- resource_type = resource.__class__
640
721
 
641
- else:
642
- resource_type = Resource.get_by_payload(self.resource_types, resource)
643
- if not resource_type:
644
- raise SCIMRequestError(
645
- "Cannot guess resource type from the payload",
646
- source=resource,
647
- )
722
+ class BaseAsyncSCIMClient(BaseSCIMClient):
723
+ """Base class for asynchronous request clients."""
648
724
 
649
- try:
650
- resource = resource_type.model_validate(resource)
651
- except ValidationError as exc:
652
- scim_validation_exc = RequestPayloadValidationError(source=resource)
653
- if sys.version_info >= (3, 11): # pragma: no cover
654
- scim_validation_exc.add_note(str(exc))
655
- raise scim_validation_exc from exc
725
+ async def create(
726
+ self,
727
+ resource: Union[AnyResource, dict],
728
+ check_request_payload: bool = True,
729
+ check_response_payload: bool = True,
730
+ expected_status_codes: Optional[
731
+ list[int]
732
+ ] = BaseSCIMClient.CREATION_RESPONSE_STATUS_CODES,
733
+ raise_scim_errors: bool = True,
734
+ **kwargs,
735
+ ) -> Union[AnyResource, Error, dict]:
736
+ """Perform a POST request to create, as defined in :rfc:`RFC7644 §3.3 <7644#section-3.3>`.
656
737
 
657
- self.check_resource_type(resource_type)
738
+ :param resource: The resource to create
739
+ If is a :data:`dict`, the resource type will be guessed from the schema.
740
+ :param check_request_payload: If :data:`False`,
741
+ :code:`resource` is expected to be a dict that will be passed as-is in the request.
742
+ :param check_response_payload: Whether to validate that the response payload is valid.
743
+ If set, the raw payload will be returned.
744
+ :param expected_status_codes: The list of expected status codes form the response.
745
+ If :data:`None` any status code is accepted.
746
+ :param raise_scim_errors: If :data:`True` and the server returned an
747
+ :class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
748
+ exception will be raised. If :data:`False` the error object is returned.
749
+ :param kwargs: Additional parameters passed to the underlying HTTP request
750
+ library.
658
751
 
659
- if not resource.id:
660
- raise SCIMRequestError("Resource must have an id", source=resource)
752
+ :return:
753
+ - An :class:`~scim2_models.Error` object in case of error.
754
+ - The created object as returned by the server in case of success and :code:`check_response_payload` is :data:`True`.
755
+ - The created object payload as returned by the server in case of success and :code:`check_response_payload` is :data:`False`.
661
756
 
662
- payload = resource.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST)
663
- url = kwargs.pop(
664
- "url", self.resource_endpoint(resource.__class__) + f"/{resource.id}"
665
- )
757
+ .. code-block:: python
758
+ :caption: Creation of a `User` resource
666
759
 
667
- try:
668
- response = self.client.put(url, json=payload, **kwargs)
669
- except RequestError as exc:
670
- scim_network_exc = RequestNetworkError(source=payload)
671
- if sys.version_info >= (3, 11): # pragma: no cover
672
- scim_network_exc.add_note(str(exc))
673
- raise scim_network_exc from exc
760
+ from scim2_models import User
674
761
 
675
- return self.check_response(
676
- response=response,
677
- expected_status_codes=expected_status_codes,
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,
681
- scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
682
- )
762
+ request = User(user_name="bjensen@example.com")
763
+ response = scim.create(request)
764
+ # 'response' may be a User or an Error object
683
765
 
684
- def modify(
685
- self, resource: Union[AnyResource, dict], op: PatchOp, **kwargs
686
- ) -> Optional[Union[AnyResource, dict]]:
766
+ .. tip::
767
+
768
+ Check the :attr:`~scim2_models.Context.RESOURCE_CREATION_REQUEST`
769
+ and :attr:`~scim2_models.Context.RESOURCE_CREATION_RESPONSE` contexts to understand
770
+ which value will excluded from the request payload, and which values are expected in
771
+ the response payload.
772
+ """
773
+ raise NotImplementedError()
774
+
775
+ async def query(
776
+ self,
777
+ resource_model: Optional[type[Resource]] = None,
778
+ id: Optional[str] = None,
779
+ search_request: Optional[Union[SearchRequest, dict]] = None,
780
+ check_request_payload: bool = True,
781
+ check_response_payload: bool = True,
782
+ expected_status_codes: Optional[
783
+ list[int]
784
+ ] = BaseSCIMClient.QUERY_RESPONSE_STATUS_CODES,
785
+ raise_scim_errors: bool = True,
786
+ **kwargs,
787
+ ) -> Union[AnyResource, ListResponse[AnyResource], Error, dict]:
788
+ """Perform a GET request to read resources, as defined in :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>`.
789
+
790
+ - If `id` is not :data:`None`, the resource with the exact id will be reached.
791
+ - If `id` is :data:`None`, all the resources with the given type will be reached.
792
+
793
+ :param resource_model: A :class:`~scim2_models.Resource` subtype or :data:`None`
794
+ :param id: The SCIM id of an object to get, or :data:`None`
795
+ :param search_request: An object detailing the search query parameters.
796
+ :param check_request_payload: If :data:`False`,
797
+ :code:`search_request` is expected to be a dict that will be passed as-is in the request.
798
+ :param check_response_payload: Whether to validate that the response payload is valid.
799
+ If set, the raw payload will be returned.
800
+ :param expected_status_codes: The list of expected status codes form the response.
801
+ If :data:`None` any status code is accepted.
802
+ :param raise_scim_errors: If :data:`True` and the server returned an
803
+ :class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
804
+ exception will be raised. If :data:`False` the error object is returned.
805
+ :param kwargs: Additional parameters passed to the underlying HTTP request library.
806
+
807
+ :return:
808
+ - A :class:`~scim2_models.Error` object in case of error.
809
+ - A `resource_model` object in case of success if `id` is not :data:`None`
810
+ - A :class:`~scim2_models.ListResponse[resource_model]` object in case of success if `id` is :data:`None`
811
+
812
+ .. note::
813
+
814
+ Querying a :class:`~scim2_models.ServiceProviderConfig` will return a
815
+ single object, and not a :class:`~scim2_models.ListResponse`.
816
+
817
+ :usage:
818
+
819
+ .. code-block:: python
820
+ :caption: Query of a `User` resource knowing its id
821
+
822
+ from scim2_models import User
823
+
824
+ response = scim.query(User, "my-user-id)
825
+ # 'response' may be a User or an Error object
826
+
827
+ .. code-block:: python
828
+ :caption: Query of all the `User` resources filtering the ones with `userName` starts with `john`
829
+
830
+ from scim2_models import User, SearchRequest
831
+
832
+ req = SearchRequest(filter='userName sw "john"')
833
+ response = scim.query(User, search_request=search_request)
834
+ # 'response' may be a ListResponse[User] or an Error object
835
+
836
+ .. code-block:: python
837
+ :caption: Query of all the available resources
838
+
839
+ from scim2_models import User, SearchRequest
840
+
841
+ response = scim.query()
842
+ # 'response' may be a ListResponse[Union[User, Group, ...]] or an Error object
843
+
844
+ .. tip::
845
+
846
+ Check the :attr:`~scim2_models.Context.RESOURCE_QUERY_REQUEST`
847
+ and :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE` contexts to understand
848
+ which value will excluded from the request payload, and which values are expected in
849
+ the response payload.
850
+ """
851
+ raise NotImplementedError()
852
+
853
+ async def search(
854
+ self,
855
+ search_request: Optional[SearchRequest] = None,
856
+ check_request_payload: bool = True,
857
+ check_response_payload: bool = True,
858
+ expected_status_codes: Optional[
859
+ list[int]
860
+ ] = BaseSCIMClient.SEARCH_RESPONSE_STATUS_CODES,
861
+ raise_scim_errors: bool = True,
862
+ **kwargs,
863
+ ) -> Union[AnyResource, ListResponse[AnyResource], Error, dict]:
864
+ """Perform a POST search request to read all available resources, as defined in :rfc:`RFC7644 §3.4.3 <7644#section-3.4.3>`.
865
+
866
+ :param resource_models: Resource type or union of types expected
867
+ to be read from the response.
868
+ :param search_request: An object detailing the search query parameters.
869
+ :param check_request_payload: If :data:`False`,
870
+ :code:`search_request` is expected to be a dict that will be passed as-is in the request.
871
+ :param check_response_payload: Whether to validate that the response payload is valid.
872
+ If set, the raw payload will be returned.
873
+ :param expected_status_codes: The list of expected status codes form the response.
874
+ If :data:`None` any status code is accepted.
875
+ :param raise_scim_errors: If :data:`True` and the server returned an
876
+ :class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
877
+ exception will be raised. If :data:`False` the error object is returned.
878
+ :param kwargs: Additional parameters passed to the underlying
879
+ HTTP request library.
880
+
881
+ :return:
882
+ - A :class:`~scim2_models.Error` object in case of error.
883
+ - A :class:`~scim2_models.ListResponse[resource_model]` object in case of success.
884
+
885
+ :usage:
886
+
887
+ .. code-block:: python
888
+ :caption: Searching for all the resources filtering the ones with `id` contains with `admin`
889
+
890
+ from scim2_models import User, SearchRequest
891
+
892
+ req = SearchRequest(filter='id co "john"')
893
+ response = scim.search(search_request=search_request)
894
+ # 'response' may be a ListResponse[User] or an Error object
895
+
896
+ .. tip::
897
+
898
+ Check the :attr:`~scim2_models.Context.SEARCH_REQUEST`
899
+ and :attr:`~scim2_models.Context.SEARCH_RESPONSE` contexts to understand
900
+ which value will excluded from the request payload, and which values are expected in
901
+ the response payload.
902
+ """
903
+ raise NotImplementedError()
904
+
905
+ async def delete(
906
+ self,
907
+ resource_model: type,
908
+ id: str,
909
+ check_response_payload: bool = True,
910
+ expected_status_codes: Optional[
911
+ list[int]
912
+ ] = BaseSCIMClient.DELETION_RESPONSE_STATUS_CODES,
913
+ raise_scim_errors: bool = True,
914
+ **kwargs,
915
+ ) -> Optional[Union[Error, dict]]:
916
+ """Perform a DELETE request to create, as defined in :rfc:`RFC7644 §3.6 <7644#section-3.6>`.
917
+
918
+ :param resource_model: The type of the resource to delete.
919
+ :param id: The type id the resource to delete.
920
+ :param check_response_payload: Whether to validate that the response payload is valid.
921
+ If set, the raw payload will be returned.
922
+ :param expected_status_codes: The list of expected status codes form the response.
923
+ If :data:`None` any status code is accepted.
924
+ :param raise_scim_errors: If :data:`True` and the server returned an
925
+ :class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
926
+ exception will be raised. If :data:`False` the error object is returned.
927
+ :param kwargs: Additional parameters passed to the underlying
928
+ HTTP request library.
929
+
930
+ :return:
931
+ - A :class:`~scim2_models.Error` object in case of error.
932
+ - :data:`None` in case of success.
933
+
934
+ :usage:
935
+
936
+ .. code-block:: python
937
+ :caption: Deleting an `User` which `id` is `foobar`
938
+
939
+ from scim2_models import User, SearchRequest
940
+
941
+ response = scim.delete(User, "foobar")
942
+ # 'response' may be None, or an Error object
943
+ """
944
+ raise NotImplementedError()
945
+
946
+ async def replace(
947
+ self,
948
+ resource: Union[AnyResource, dict],
949
+ check_request_payload: bool = True,
950
+ check_response_payload: bool = True,
951
+ expected_status_codes: Optional[
952
+ list[int]
953
+ ] = BaseSCIMClient.REPLACEMENT_RESPONSE_STATUS_CODES,
954
+ raise_scim_errors: bool = True,
955
+ **kwargs,
956
+ ) -> Union[AnyResource, Error, dict]:
957
+ """Perform a PUT request to replace a resource, as defined in :rfc:`RFC7644 §3.5.1 <7644#section-3.5.1>`.
958
+
959
+ :param resource: The new resource to replace.
960
+ If is a :data:`dict`, the resource type will be guessed from the schema.
961
+ :param check_request_payload: If :data:`False`,
962
+ :code:`resource` is expected to be a dict that will be passed as-is in the request.
963
+ :param check_response_payload: Whether to validate that the response payload is valid.
964
+ If set, the raw payload will be returned.
965
+ :param expected_status_codes: The list of expected status codes form the response.
966
+ If :data:`None` any status code is accepted.
967
+ :param raise_scim_errors: If :data:`True` and the server returned an
968
+ :class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
969
+ exception will be raised. If :data:`False` the error object is returned.
970
+ :param kwargs: Additional parameters passed to the underlying
971
+ HTTP request library.
972
+
973
+ :return:
974
+ - An :class:`~scim2_models.Error` object in case of error.
975
+ - The updated object as returned by the server in case of success.
976
+
977
+ :usage:
978
+
979
+ .. code-block:: python
980
+ :caption: Replacement of a `User` resource
981
+
982
+ from scim2_models import User
983
+
984
+ user = scim.query(User, "my-used-id")
985
+ user.display_name = "Fancy New Name"
986
+ updated_user = scim.replace(user)
987
+
988
+ .. tip::
989
+
990
+ Check the :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`
991
+ and :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_RESPONSE` contexts to understand
992
+ which value will excluded from the request payload, and which values are expected in
993
+ the response payload.
994
+ """
687
995
  raise NotImplementedError()