scim2-client 0.2.1__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,42 +134,47 @@ class SCIMClient:
128
134
  :rfc:`RFC7644 §3.12 <7644#section-3.12>`.
129
135
  """
130
136
 
131
- def __init__(self, client: Client, resource_types: Optional[tuple[type]] = None):
132
- self.client = client
133
- self.resource_types = tuple(
134
- 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}
135
140
  )
136
141
 
137
- def check_resource_type(self, resource_type):
138
- if resource_type not in self.resource_types:
139
- 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
+ )
140
149
 
141
- def resource_endpoint(self, resource_type: type):
142
- if resource_type is None:
150
+ def resource_endpoint(self, resource_model: Optional[type[Resource]]) -> str:
151
+ if resource_model is None:
143
152
  return "/"
144
153
 
145
154
  # This one takes no final 's'
146
- if resource_type is ServiceProviderConfig:
155
+ if resource_model is ServiceProviderConfig:
147
156
  return "/ServiceProviderConfig"
148
157
 
149
158
  try:
150
- first_bracket_index = resource_type.__name__.index("[")
151
- 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]
152
161
  except ValueError:
153
- root_name = resource_type.__name__
162
+ root_name = resource_model.__name__
154
163
  return f"/{root_name}s"
155
164
 
156
165
  def check_response(
157
166
  self,
158
- response: Response,
159
- expected_status_codes: list[int],
160
- expected_types: Optional[type] = None,
167
+ payload: Optional[dict],
168
+ status_code: int,
169
+ headers: dict,
170
+ expected_status_codes: Optional[list[int]] = None,
171
+ expected_types: Optional[list[type[Resource]]] = None,
161
172
  check_response_payload: bool = True,
162
173
  raise_scim_errors: bool = True,
163
174
  scim_ctx: Optional[Context] = None,
164
- ):
165
- if expected_status_codes and response.status_code not in expected_status_codes:
166
- raise UnexpectedStatusCode(source=response)
175
+ ) -> Union[Error, None, dict, type[Resource]]:
176
+ if expected_status_codes and status_code not in expected_status_codes:
177
+ raise UnexpectedStatusCode()
167
178
 
168
179
  # Interoperability considerations: The "application/scim+json" media
169
180
  # type is intended to identify JSON structure data that conforms to
@@ -171,24 +182,21 @@ class SCIMClient:
171
182
  # SCIM are known to informally use "application/json".
172
183
  # https://datatracker.ietf.org/doc/html/rfc7644.html#section-8.1
173
184
 
174
- actual_content_type = response.headers.get("content-type", "").split(";").pop(0)
185
+ actual_content_type = headers.get("content-type", "").split(";").pop(0)
175
186
  expected_response_content_types = ("application/scim+json", "application/json")
176
187
  if actual_content_type not in expected_response_content_types:
177
- raise UnexpectedContentType(source=response)
188
+ raise UnexpectedContentType(content_type=actual_content_type)
178
189
 
179
190
  # In addition to returning an HTTP response code, implementers MUST return
180
191
  # the errors in the body of the response in a JSON format
181
192
  # https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
182
193
 
183
194
  no_content_status_codes = [204, 205]
184
- if response.status_code in no_content_status_codes:
195
+ if status_code in no_content_status_codes:
185
196
  response_payload = None
186
197
 
187
198
  else:
188
- try:
189
- response_payload = response.json()
190
- except json.decoder.JSONDecodeError as exc:
191
- raise UnexpectedContentFormat(source=response) from exc
199
+ response_payload = payload
192
200
 
193
201
  if not check_response_payload:
194
202
  return response_payload
@@ -209,8 +217,8 @@ class SCIMClient:
209
217
  expected_types, response_payload, with_extensions=False
210
218
  )
211
219
 
212
- if not actual_type:
213
- expected = ", ".join([type.__name__ for type in expected_types])
220
+ if response_payload and not actual_type:
221
+ expected = ", ".join([type_.__name__ for type_ in expected_types])
214
222
  try:
215
223
  schema = ", ".join(response_payload["schemas"])
216
224
  message = f"Expected type {expected} but got unknown resource with schemas: {schema}"
@@ -219,22 +227,233 @@ class SCIMClient:
219
227
  f"Expected type {expected} but got undefined object with no schema"
220
228
  )
221
229
 
222
- raise SCIMResponseError(message, source=response)
230
+ raise SCIMResponseError(message)
223
231
 
224
232
  try:
225
233
  return actual_type.model_validate(response_payload, scim_ctx=scim_ctx)
226
234
  except ValidationError as exc:
227
- scim_exc = ResponsePayloadValidationError(source=response)
235
+ scim_exc = ResponsePayloadValidationError()
228
236
  if sys.version_info >= (3, 11): # pragma: no cover
229
237
  scim_exc.add_note(str(exc))
230
238
  raise scim_exc from exc
231
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
+
232
449
  def create(
233
450
  self,
234
451
  resource: Union[AnyResource, dict],
235
452
  check_request_payload: bool = True,
236
453
  check_response_payload: bool = True,
237
- expected_status_codes: Optional[list[int]] = CREATION_RESPONSE_STATUS_CODES,
454
+ expected_status_codes: Optional[
455
+ list[int]
456
+ ] = BaseSCIMClient.CREATION_RESPONSE_STATUS_CODES,
238
457
  raise_scim_errors: bool = True,
239
458
  **kwargs,
240
459
  ) -> Union[AnyResource, Error, dict]:
@@ -275,58 +494,18 @@ class SCIMClient:
275
494
  which value will excluded from the request payload, and which values are expected in
276
495
  the response payload.
277
496
  """
278
- if not check_request_payload:
279
- payload = resource
280
- url = kwargs.pop("url", None)
281
-
282
- else:
283
- if isinstance(resource, Resource):
284
- resource_type = resource.__class__
285
-
286
- else:
287
- resource_type = Resource.get_by_payload(self.resource_types, resource)
288
- if not resource_type:
289
- raise SCIMRequestError(
290
- "Cannot guess resource type from the payload"
291
- )
292
-
293
- try:
294
- resource = resource_type.model_validate(resource)
295
- except ValidationError as exc:
296
- scim_exc = RequestPayloadValidationError(source=resource)
297
- if sys.version_info >= (3, 11): # pragma: no cover
298
- scim_exc.add_note(str(exc))
299
- raise scim_exc from exc
300
-
301
- self.check_resource_type(resource_type)
302
- url = kwargs.pop("url", self.resource_endpoint(resource_type))
303
- payload = resource.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST)
304
-
305
- try:
306
- response = self.client.post(url, json=payload, **kwargs)
307
- except RequestError as exc:
308
- scim_exc = RequestNetworkError(source=payload)
309
- if sys.version_info >= (3, 11): # pragma: no cover
310
- scim_exc.add_note(str(exc))
311
- raise scim_exc from exc
312
-
313
- return self.check_response(
314
- response=response,
315
- expected_status_codes=expected_status_codes,
316
- expected_types=([resource.__class__] if check_request_payload else None),
317
- check_response_payload=check_response_payload,
318
- raise_scim_errors=raise_scim_errors,
319
- scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
320
- )
497
+ raise NotImplementedError()
321
498
 
322
499
  def query(
323
500
  self,
324
- resource_type: Optional[type] = None,
501
+ resource_model: Optional[type[Resource]] = None,
325
502
  id: Optional[str] = None,
326
503
  search_request: Optional[Union[SearchRequest, dict]] = None,
327
504
  check_request_payload: bool = True,
328
505
  check_response_payload: bool = True,
329
- expected_status_codes: Optional[list[int]] = QUERY_RESPONSE_STATUS_CODES,
506
+ expected_status_codes: Optional[
507
+ list[int]
508
+ ] = BaseSCIMClient.QUERY_RESPONSE_STATUS_CODES,
330
509
  raise_scim_errors: bool = True,
331
510
  **kwargs,
332
511
  ) -> Union[AnyResource, ListResponse[AnyResource], Error, dict]:
@@ -335,7 +514,7 @@ class SCIMClient:
335
514
  - If `id` is not :data:`None`, the resource with the exact id will be reached.
336
515
  - If `id` is :data:`None`, all the resources with the given type will be reached.
337
516
 
338
- :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`
339
518
  :param id: The SCIM id of an object to get, or :data:`None`
340
519
  :param search_request: An object detailing the search query parameters.
341
520
  :param check_request_payload: If :data:`False`,
@@ -351,8 +530,8 @@ class SCIMClient:
351
530
 
352
531
  :return:
353
532
  - A :class:`~scim2_models.Error` object in case of error.
354
- - A `resource_type` object in case of success if `id` is not :data:`None`
355
- - 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`
356
535
 
357
536
  .. note::
358
537
 
@@ -393,71 +572,22 @@ class SCIMClient:
393
572
  which value will excluded from the request payload, and which values are expected in
394
573
  the response payload.
395
574
  """
396
- if resource_type and check_request_payload:
397
- self.check_resource_type(resource_type)
398
-
399
- if not check_request_payload:
400
- payload = search_request
401
-
402
- else:
403
- payload = (
404
- search_request.model_dump(
405
- exclude_unset=True,
406
- scim_ctx=Context.RESOURCE_QUERY_REQUEST,
407
- )
408
- if search_request
409
- else None
410
- )
411
-
412
- url = kwargs.pop("url", self.resource_endpoint(resource_type))
413
-
414
- if resource_type is None:
415
- expected_types = [
416
- *self.resource_types,
417
- ListResponse[Union[self.resource_types]],
418
- ]
419
-
420
- elif resource_type == ServiceProviderConfig:
421
- expected_types = [resource_type]
422
- if id:
423
- raise SCIMClientError("ServiceProviderConfig cannot have an id")
424
-
425
- elif id:
426
- expected_types = [resource_type]
427
- url = f"{url}/{id}"
428
-
429
- else:
430
- expected_types = [ListResponse[resource_type]]
431
-
432
- try:
433
- response = self.client.get(url, params=payload, **kwargs)
434
- except RequestError as exc:
435
- scim_exc = RequestNetworkError(source=payload)
436
- if sys.version_info >= (3, 11): # pragma: no cover
437
- scim_exc.add_note(str(exc))
438
- raise scim_exc from exc
439
-
440
- return self.check_response(
441
- response=response,
442
- expected_status_codes=expected_status_codes,
443
- expected_types=expected_types,
444
- check_response_payload=check_response_payload,
445
- raise_scim_errors=raise_scim_errors,
446
- scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
447
- )
575
+ raise NotImplementedError()
448
576
 
449
577
  def search(
450
578
  self,
451
579
  search_request: Optional[SearchRequest] = None,
452
580
  check_request_payload: bool = True,
453
581
  check_response_payload: bool = True,
454
- expected_status_codes: Optional[list[int]] = SEARCH_RESPONSE_STATUS_CODES,
582
+ expected_status_codes: Optional[
583
+ list[int]
584
+ ] = BaseSCIMClient.SEARCH_RESPONSE_STATUS_CODES,
455
585
  raise_scim_errors: bool = True,
456
586
  **kwargs,
457
587
  ) -> Union[AnyResource, ListResponse[AnyResource], Error, dict]:
458
588
  """Perform a POST search request to read all available resources, as defined in :rfc:`RFC7644 §3.4.3 <7644#section-3.4.3>`.
459
589
 
460
- :param resource_types: Resource type or union of types expected
590
+ :param resource_models: Resource type or union of types expected
461
591
  to be read from the response.
462
592
  :param search_request: An object detailing the search query parameters.
463
593
  :param check_request_payload: If :data:`False`,
@@ -474,7 +604,7 @@ class SCIMClient:
474
604
 
475
605
  :return:
476
606
  - A :class:`~scim2_models.Error` object in case of error.
477
- - 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.
478
608
 
479
609
  :usage:
480
610
 
@@ -494,49 +624,22 @@ class SCIMClient:
494
624
  which value will excluded from the request payload, and which values are expected in
495
625
  the response payload.
496
626
  """
497
- if not check_request_payload:
498
- payload = search_request
499
-
500
- else:
501
- payload = (
502
- search_request.model_dump(
503
- exclude_unset=True, scim_ctx=Context.RESOURCE_QUERY_RESPONSE
504
- )
505
- if search_request
506
- else None
507
- )
508
-
509
- url = kwargs.pop("url", "/.search")
510
-
511
- try:
512
- response = self.client.post(url, json=payload)
513
- except RequestError as exc:
514
- scim_exc = RequestNetworkError(source=payload)
515
- if sys.version_info >= (3, 11): # pragma: no cover
516
- scim_exc.add_note(str(exc))
517
- raise scim_exc from exc
518
-
519
- return self.check_response(
520
- response=response,
521
- expected_status_codes=expected_status_codes,
522
- expected_types=[ListResponse[Union[self.resource_types]]],
523
- check_response_payload=check_response_payload,
524
- raise_scim_errors=raise_scim_errors,
525
- scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
526
- )
627
+ raise NotImplementedError()
527
628
 
528
629
  def delete(
529
630
  self,
530
- resource_type: type,
631
+ resource_model: type,
531
632
  id: str,
532
633
  check_response_payload: bool = True,
533
- expected_status_codes: Optional[list[int]] = DELETION_RESPONSE_STATUS_CODES,
634
+ expected_status_codes: Optional[
635
+ list[int]
636
+ ] = BaseSCIMClient.DELETION_RESPONSE_STATUS_CODES,
534
637
  raise_scim_errors: bool = True,
535
638
  **kwargs,
536
639
  ) -> Optional[Union[Error, dict]]:
537
640
  """Perform a DELETE request to create, as defined in :rfc:`RFC7644 §3.6 <7644#section-3.6>`.
538
641
 
539
- :param resource_type: The type of the resource to delete.
642
+ :param resource_model: The type of the resource to delete.
540
643
  :param id: The type id the resource to delete.
541
644
  :param check_response_payload: Whether to validate that the response payload is valid.
542
645
  If set, the raw payload will be returned.
@@ -562,31 +665,16 @@ class SCIMClient:
562
665
  response = scim.delete(User, "foobar")
563
666
  # 'response' may be None, or an Error object
564
667
  """
565
- self.check_resource_type(resource_type)
566
- delete_url = self.resource_endpoint(resource_type) + f"/{id}"
567
- url = kwargs.pop("url", delete_url)
568
-
569
- try:
570
- response = self.client.delete(url, **kwargs)
571
- except RequestError as exc:
572
- scim_exc = RequestNetworkError()
573
- if sys.version_info >= (3, 11): # pragma: no cover
574
- scim_exc.add_note(str(exc))
575
- raise scim_exc from exc
576
-
577
- return self.check_response(
578
- response=response,
579
- expected_status_codes=expected_status_codes,
580
- check_response_payload=check_response_payload,
581
- raise_scim_errors=raise_scim_errors,
582
- )
668
+ raise NotImplementedError()
583
669
 
584
670
  def replace(
585
671
  self,
586
672
  resource: Union[AnyResource, dict],
587
673
  check_request_payload: bool = True,
588
674
  check_response_payload: bool = True,
589
- expected_status_codes: Optional[list[int]] = REPLACEMENT_RESPONSE_STATUS_CODES,
675
+ expected_status_codes: Optional[
676
+ list[int]
677
+ ] = BaseSCIMClient.REPLACEMENT_RESPONSE_STATUS_CODES,
590
678
  raise_scim_errors: bool = True,
591
679
  **kwargs,
592
680
  ) -> Union[AnyResource, Error, dict]:
@@ -628,58 +716,280 @@ class SCIMClient:
628
716
  which value will excluded from the request payload, and which values are expected in
629
717
  the response payload.
630
718
  """
631
- if not check_request_payload:
632
- payload = resource
633
- url = kwargs.pop("url", None)
719
+ raise NotImplementedError()
634
720
 
635
- else:
636
- if isinstance(resource, Resource):
637
- resource_type = resource.__class__
638
721
 
639
- else:
640
- resource_type = Resource.get_by_payload(self.resource_types, resource)
641
- if not resource_type:
642
- raise SCIMRequestError(
643
- "Cannot guess resource type from the payload",
644
- source=resource,
645
- )
722
+ class BaseAsyncSCIMClient(BaseSCIMClient):
723
+ """Base class for asynchronous request clients."""
646
724
 
647
- try:
648
- resource = resource_type.model_validate(resource)
649
- except ValidationError as exc:
650
- scim_exc = RequestPayloadValidationError(source=resource)
651
- if sys.version_info >= (3, 11): # pragma: no cover
652
- scim_exc.add_note(str(exc))
653
- raise scim_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>`.
737
+
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.
654
751
 
655
- self.check_resource_type(resource_type)
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`.
656
756
 
657
- if not resource.id:
658
- raise SCIMRequestError("Resource must have an id", source=resource)
757
+ .. code-block:: python
758
+ :caption: Creation of a `User` resource
659
759
 
660
- payload = resource.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST)
661
- url = kwargs.pop(
662
- "url", self.resource_endpoint(resource.__class__) + f"/{resource.id}"
663
- )
760
+ from scim2_models import User
664
761
 
665
- try:
666
- response = self.client.put(url, json=payload, **kwargs)
667
- except RequestError as exc:
668
- scim_exc = RequestNetworkError(source=payload)
669
- if sys.version_info >= (3, 11): # pragma: no cover
670
- scim_exc.add_note(str(exc))
671
- raise scim_exc from exc
762
+ request = User(user_name="bjensen@example.com")
763
+ response = scim.create(request)
764
+ # 'response' may be a User or an Error object
672
765
 
673
- return self.check_response(
674
- response=response,
675
- expected_status_codes=expected_status_codes,
676
- expected_types=([resource.__class__] if check_request_payload else None),
677
- check_response_payload=check_response_payload,
678
- raise_scim_errors=raise_scim_errors,
679
- scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
680
- )
766
+ .. tip::
681
767
 
682
- def modify(
683
- self, resource: Union[AnyResource, dict], op: PatchOp, **kwargs
684
- ) -> Optional[Union[AnyResource, dict]]:
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
+ """
685
995
  raise NotImplementedError()