Skip to content

Commit f88b4e6

Browse files
authored
Merge pull request #29 from python-scim/issue-1-async
async client implementation
2 parents 6f37abe + 0529371 commit f88b4e6

File tree

8 files changed

+637
-2
lines changed

8 files changed

+637
-2
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# scim2-client
22

3-
A SCIM client Python library built upon [scim2-models](https://scim2-models.readthedocs.io) and [httpx](https://github.com/encode/httpx),
3+
A SCIM client Python library built upon [scim2-models](https://scim2-models.readthedocs.io) ,
44
that pythonically build requests and parse responses,
55
following the [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643.html) and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644.html) specifications.
6+
You can use whatever request engine you prefer to perform network requests, but scim2-models comes with [httpx](https://github.com/encode/httpx) support.
67

78
It aims to be used in SCIM client applications, or in unit tests for SCIM server applications.
89

@@ -16,7 +17,7 @@ It allows users and groups creations, modifications and deletions to be synchron
1617
## Installation
1718

1819
```shell
19-
pip install scim2-client
20+
pip install scim2-client[httpx]
2021
```
2122

2223
## Usage

doc/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Added
1616
^^^^^
1717
- The `Unknown resource type` request error keeps a reference to the faulty payload.
1818
- New `werkzeug` request engine for application development purpose.
19+
- New `AsyncSCIMClient` request engine. :issue:`1`
1920

2021
Changed
2122
^^^^^^^

doc/tutorial.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ scim2-client comes with a light abstraction layers that allows for different req
9090
Currently those engines are shipped:
9191

9292
- :class:`~scim2_client.engines.httpx.SyncSCIMClient`: A synchronous engine using `httpx <https://github.com/encode/httpx>`_ to perform the HTTP requests.
93+
- :class:`~scim2_client.engines.httpx.AsyncSCIMClient`: An asynchronous engine using `httpx <https://github.com/encode/httpx>`_ to perform the HTTP requests. It has the very same API than its synchronous version, except it is asynchronous.
9394
- :class:`~scim2_client.engines.werkzeug.TestSCIMClient`: A test engine for development purposes.
9495
It takes a WSGI app and directly execute the server code instead of performing real HTTP requests.
9596
This is faster in unit test suites, and helpful to catch the server exceptions.

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@ funding = "https://github.com/sponsors/python-scim"
4848
[dependency-groups]
4949
dev = [
5050
"mypy>=1.13.0",
51+
"portpicker>=1.6.0",
5152
"pre-commit-uv>=4.1.4",
5253
"pytest>=8.2.1",
54+
"pytest-asyncio>=0.24.0",
5355
"pytest-coverage>=0.0",
5456
"pytest-httpserver>=1.0.10",
5557
"scim2-server >= 0.1.2; python_version>='3.10'",
@@ -115,6 +117,10 @@ plugins = [
115117
"pydantic.mypy"
116118
]
117119

120+
[tool.pytest.ini_options]
121+
asyncio_mode="auto"
122+
asyncio_default_fixture_loop_scope = "function"
123+
118124
[tool.tox]
119125
requires = ["tox>=4.19"]
120126
env_list = [

scim2_client/client.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,3 +717,279 @@ def replace(
717717
the response payload.
718718
"""
719719
raise NotImplementedError()
720+
721+
722+
class BaseAsyncSCIMClient(BaseSCIMClient):
723+
"""Base class for asynchronous request clients."""
724+
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.
751+
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`.
756+
757+
.. code-block:: python
758+
:caption: Creation of a `User` resource
759+
760+
from scim2_models import User
761+
762+
request = User(user_name="[email protected]")
763+
response = scim.create(request)
764+
# 'response' may be a User or an Error object
765+
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+
"""
995+
raise NotImplementedError()

0 commit comments

Comments
 (0)