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