scim2-client 0.1.1__tar.gz → 0.1.3__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.1 → scim2_client-0.1.3}/PKG-INFO +3 -3
- {scim2_client-0.1.1 → scim2_client-0.1.3}/README.md +1 -1
- {scim2_client-0.1.1 → scim2_client-0.1.3}/pyproject.toml +2 -2
- scim2_client-0.1.3/scim2_client/client.py +471 -0
- scim2_client-0.1.1/scim2_client/client.py +0 -377
- {scim2_client-0.1.1 → scim2_client-0.1.3}/scim2_client/__init__.py +0 -0
- {scim2_client-0.1.1 → scim2_client-0.1.3}/scim2_client/errors.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: scim2-client
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
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.2,<0.2.0)
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
|
|
26
26
|
# scim2-client
|
|
@@ -42,7 +42,7 @@ Here is an example of usage:
|
|
|
42
42
|
|
|
43
43
|
```python
|
|
44
44
|
import datetime
|
|
45
|
-
from httpx
|
|
45
|
+
from httpx import Client
|
|
46
46
|
from scim2_models import User, EnterpriseUserUser, Group, Error
|
|
47
47
|
from scim2_client import SCIMClient
|
|
48
48
|
|
|
@@ -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.3"
|
|
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.2"
|
|
31
31
|
|
|
32
32
|
[tool.poetry.group.doc]
|
|
33
33
|
optional = true
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import json.decoder
|
|
3
|
+
from typing import Dict
|
|
4
|
+
from typing import List
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from typing import Tuple
|
|
7
|
+
from typing import Type
|
|
8
|
+
from typing import Union
|
|
9
|
+
|
|
10
|
+
from httpx import Client
|
|
11
|
+
from httpx import Response
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
from scim2_models import AnyResource
|
|
14
|
+
from scim2_models import Context
|
|
15
|
+
from scim2_models import Error
|
|
16
|
+
from scim2_models import ListResponse
|
|
17
|
+
from scim2_models import PatchOp
|
|
18
|
+
from scim2_models import Resource
|
|
19
|
+
from scim2_models import SearchRequest
|
|
20
|
+
|
|
21
|
+
from .errors import SCIMClientError
|
|
22
|
+
from .errors import UnexpectedContentFormat
|
|
23
|
+
from .errors import UnexpectedContentType
|
|
24
|
+
from .errors import UnexpectedStatusCode
|
|
25
|
+
|
|
26
|
+
BASE_HEADERS = {
|
|
27
|
+
"Accept": "application/scim+json",
|
|
28
|
+
"Content-Type": "application/scim+json",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SCIMClient:
|
|
33
|
+
"""An object that perform SCIM requests and validate responses."""
|
|
34
|
+
|
|
35
|
+
CREATION_RESPONSE_STATUS_CODES: List[int] = [
|
|
36
|
+
201,
|
|
37
|
+
409,
|
|
38
|
+
307,
|
|
39
|
+
308,
|
|
40
|
+
400,
|
|
41
|
+
401,
|
|
42
|
+
403,
|
|
43
|
+
404,
|
|
44
|
+
500,
|
|
45
|
+
]
|
|
46
|
+
"""Resource creation HTTP codes defined at :rfc:`RFC7644 §3.3
|
|
47
|
+
<7644#section-3.3>` and :rfc:`RFC7644 §3.12 <7644#section-3.12>`"""
|
|
48
|
+
|
|
49
|
+
QUERY_RESPONSE_STATUS_CODES: List[int] = [200, 400, 307, 308, 401, 403, 404, 500]
|
|
50
|
+
"""Resource querying HTTP codes defined at :rfc:`RFC7644 §3.4.2
|
|
51
|
+
<7644#section-3.4.2>` and :rfc:`RFC7644 §3.12 <7644#section-3.12>`"""
|
|
52
|
+
|
|
53
|
+
SEARCH_RESPONSE_STATUS_CODES: List[int] = [
|
|
54
|
+
200,
|
|
55
|
+
307,
|
|
56
|
+
308,
|
|
57
|
+
400,
|
|
58
|
+
401,
|
|
59
|
+
403,
|
|
60
|
+
404,
|
|
61
|
+
409,
|
|
62
|
+
413,
|
|
63
|
+
500,
|
|
64
|
+
501,
|
|
65
|
+
]
|
|
66
|
+
"""Resource querying HTTP codes defined at :rfc:`RFC7644 §3.4.3
|
|
67
|
+
<7644#section-3.4.3>` and :rfc:`RFC7644 §3.12 <7644#section-3.12>`"""
|
|
68
|
+
|
|
69
|
+
DELETION_RESPONSE_STATUS_CODES: List[int] = [
|
|
70
|
+
204,
|
|
71
|
+
307,
|
|
72
|
+
308,
|
|
73
|
+
400,
|
|
74
|
+
401,
|
|
75
|
+
403,
|
|
76
|
+
404,
|
|
77
|
+
412,
|
|
78
|
+
500,
|
|
79
|
+
501,
|
|
80
|
+
]
|
|
81
|
+
"""Resource deletion HTTP codes defined at :rfc:`RFC7644 §3.6
|
|
82
|
+
<7644#section-3.6>` and :rfc:`RFC7644 §3.12 <7644#section-3.12>`"""
|
|
83
|
+
|
|
84
|
+
REPLACEMENT_RESPONSE_STATUS_CODES: List[int] = [
|
|
85
|
+
200,
|
|
86
|
+
307,
|
|
87
|
+
308,
|
|
88
|
+
400,
|
|
89
|
+
401,
|
|
90
|
+
403,
|
|
91
|
+
404,
|
|
92
|
+
409,
|
|
93
|
+
412,
|
|
94
|
+
500,
|
|
95
|
+
501,
|
|
96
|
+
]
|
|
97
|
+
"""Resource querying HTTP codes defined at :rfc:`RFC7644 §3.4.2
|
|
98
|
+
<7644#section-3.4.2>` and :rfc:`RFC7644 §3.12 <7644#section-3.12>`"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, client: Client, resource_types: Optional[Tuple[Type]] = None):
|
|
101
|
+
self.client = client
|
|
102
|
+
self.resource_types = resource_types or ()
|
|
103
|
+
|
|
104
|
+
def check_resource_type(self, resource_type):
|
|
105
|
+
if resource_type not in self.resource_types:
|
|
106
|
+
raise ValueError(f"Unknown resource type: '{resource_type}'")
|
|
107
|
+
|
|
108
|
+
def resource_endpoint(self, resource_type: Type):
|
|
109
|
+
return f"/{resource_type.__name__}s"
|
|
110
|
+
|
|
111
|
+
def check_response(
|
|
112
|
+
self,
|
|
113
|
+
response: Response,
|
|
114
|
+
expected_status_codes: List[int],
|
|
115
|
+
expected_type: Optional[Type] = None,
|
|
116
|
+
scim_ctx: Optional[Context] = None,
|
|
117
|
+
):
|
|
118
|
+
if expected_status_codes and response.status_code not in expected_status_codes:
|
|
119
|
+
raise UnexpectedStatusCode(response)
|
|
120
|
+
|
|
121
|
+
# Interoperability considerations: The "application/scim+json" media
|
|
122
|
+
# type is intended to identify JSON structure data that conforms to
|
|
123
|
+
# the SCIM protocol and schema specifications. Older versions of
|
|
124
|
+
# SCIM are known to informally use "application/json".
|
|
125
|
+
# https://datatracker.ietf.org/doc/html/rfc7644.html#section-8.1
|
|
126
|
+
|
|
127
|
+
expected_response_content_types = ("application/scim+json", "application/json")
|
|
128
|
+
if response.headers.get("content-type") not in expected_response_content_types:
|
|
129
|
+
raise UnexpectedContentType(response)
|
|
130
|
+
|
|
131
|
+
# In addition to returning an HTTP response code, implementers MUST return
|
|
132
|
+
# the errors in the body of the response in a JSON format
|
|
133
|
+
# https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
|
|
134
|
+
|
|
135
|
+
no_content_status_codes = [204, 205]
|
|
136
|
+
if response.status_code in no_content_status_codes:
|
|
137
|
+
response_payload = None
|
|
138
|
+
|
|
139
|
+
else:
|
|
140
|
+
try:
|
|
141
|
+
response_payload = response.json()
|
|
142
|
+
except json.decoder.JSONDecodeError as exc:
|
|
143
|
+
raise UnexpectedContentFormat(response) from exc
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
return Error.model_validate(response_payload)
|
|
147
|
+
except ValidationError:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
if expected_type:
|
|
151
|
+
try:
|
|
152
|
+
return expected_type.model_validate(response_payload, scim_ctx=scim_ctx)
|
|
153
|
+
except ValidationError as exc:
|
|
154
|
+
exc.response_payload = response_payload
|
|
155
|
+
raise exc
|
|
156
|
+
|
|
157
|
+
return response_payload
|
|
158
|
+
|
|
159
|
+
def create(
|
|
160
|
+
self,
|
|
161
|
+
resource: Union[AnyResource, Dict],
|
|
162
|
+
check_request_payload: bool = True,
|
|
163
|
+
check_response_payload: bool = True,
|
|
164
|
+
check_status_code: bool = True,
|
|
165
|
+
**kwargs,
|
|
166
|
+
) -> Union[AnyResource, Error, Dict]:
|
|
167
|
+
"""Perform a POST request to create, as defined in :rfc:`RFC7644 §3.3
|
|
168
|
+
<7644#section-3.3>`.
|
|
169
|
+
|
|
170
|
+
:param resource: The resource to create
|
|
171
|
+
If is a :data:`dict`, the resource type will be guessed from the schema.
|
|
172
|
+
:param check_request_payload: If :data:`False`,
|
|
173
|
+
:code:`resource` is expected to be a dict that will be passed as-is in the request.
|
|
174
|
+
:param check_response_payload: Whether to validate that the response payload is valid.
|
|
175
|
+
If set, the raw payload will be returned.
|
|
176
|
+
:param check_status_code: Whether to validate that the response status code is valid.
|
|
177
|
+
:param kwargs: Additional parameters passed to the underlying HTTP request
|
|
178
|
+
library.
|
|
179
|
+
|
|
180
|
+
:return:
|
|
181
|
+
- An :class:`~scim2_models.Error` object in case of error.
|
|
182
|
+
- The created object as returned by the server in case of success.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
if not check_request_payload:
|
|
186
|
+
payload = resource
|
|
187
|
+
url = kwargs.pop("url", None)
|
|
188
|
+
|
|
189
|
+
else:
|
|
190
|
+
if isinstance(resource, Resource):
|
|
191
|
+
resource_type = resource.__class__
|
|
192
|
+
|
|
193
|
+
else:
|
|
194
|
+
resource_type = Resource.get_by_payload(self.resource_types, resource)
|
|
195
|
+
if not resource_type:
|
|
196
|
+
raise SCIMClientError(
|
|
197
|
+
None, "Cannot guess resource type from the payload"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
resource = resource_type.model_validate(resource)
|
|
201
|
+
|
|
202
|
+
self.check_resource_type(resource_type)
|
|
203
|
+
url = kwargs.pop("url", self.resource_endpoint(resource_type))
|
|
204
|
+
payload = resource.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST)
|
|
205
|
+
|
|
206
|
+
response = self.client.post(url, json=payload, **kwargs)
|
|
207
|
+
|
|
208
|
+
return self.check_response(
|
|
209
|
+
response,
|
|
210
|
+
self.CREATION_RESPONSE_STATUS_CODES if check_status_code else None,
|
|
211
|
+
resource.__class__
|
|
212
|
+
if check_request_payload and check_response_payload
|
|
213
|
+
else None,
|
|
214
|
+
scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def query(
|
|
218
|
+
self,
|
|
219
|
+
resource_type: Type,
|
|
220
|
+
id: Optional[str] = None,
|
|
221
|
+
search_request: Optional[Union[SearchRequest, Dict]] = None,
|
|
222
|
+
check_request_payload: bool = True,
|
|
223
|
+
check_response_payload: bool = True,
|
|
224
|
+
check_status_code: bool = True,
|
|
225
|
+
**kwargs,
|
|
226
|
+
) -> Union[AnyResource, ListResponse[AnyResource], Error, Dict]:
|
|
227
|
+
"""Perform a GET request to read resources, as defined in :rfc:`RFC7644
|
|
228
|
+
§3.4.2 <7644#section-3.4.2>`.
|
|
229
|
+
|
|
230
|
+
- If `id` is not :data:`None`, the resource with the exact id will be reached.
|
|
231
|
+
- If `id` is :data:`None`, all the resources with the given type will be reached.
|
|
232
|
+
|
|
233
|
+
:param resource_type: A :class:`~scim2_models.Resource` subtype or :data:`None`
|
|
234
|
+
:param id: The SCIM id of an object to get, or :data:`None`
|
|
235
|
+
:param search_request: An object detailing the search query parameters.
|
|
236
|
+
:param check_request_payload: If :data:`False`,
|
|
237
|
+
:code:`search_request` is expected to be a dict that will be passed as-is in the request.
|
|
238
|
+
:param check_response_payload: Whether to validate that the response payload is valid.
|
|
239
|
+
If set, the raw payload will be returned.
|
|
240
|
+
:param check_status_code: Whether to validate that the response status code is valid.
|
|
241
|
+
:param kwargs: Additional parameters passed to the underlying HTTP request library.
|
|
242
|
+
|
|
243
|
+
:return:
|
|
244
|
+
- A :class:`~scim2_models.Error` object in case of error.
|
|
245
|
+
- A `resource_type` object in case of success if `id` is not :data:`None`
|
|
246
|
+
- A :class:`~scim2_models.ListResponse[resource_type]` object in case of success if `id` is :data:`None`
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
self.check_resource_type(resource_type)
|
|
250
|
+
if not check_request_payload:
|
|
251
|
+
payload = search_request
|
|
252
|
+
|
|
253
|
+
else:
|
|
254
|
+
payload = (
|
|
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
|
+
)
|
|
262
|
+
|
|
263
|
+
if not id:
|
|
264
|
+
expected_type = ListResponse[resource_type]
|
|
265
|
+
url = self.resource_endpoint(resource_type)
|
|
266
|
+
|
|
267
|
+
else:
|
|
268
|
+
expected_type = resource_type
|
|
269
|
+
url = self.resource_endpoint(resource_type) + f"/{id}"
|
|
270
|
+
|
|
271
|
+
response = self.client.get(url, params=payload, **kwargs)
|
|
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
|
+
)
|
|
278
|
+
|
|
279
|
+
def query_all(
|
|
280
|
+
self,
|
|
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>`.
|
|
289
|
+
|
|
290
|
+
:param search_request: An object detailing the search query parameters.
|
|
291
|
+
:param check_request_payload: If :data:`False`,
|
|
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.
|
|
298
|
+
|
|
299
|
+
:return:
|
|
300
|
+
- A :class:`~scim2_models.Error` object in case of error.
|
|
301
|
+
- A :class:`~scim2_models.ListResponse[resource_type]` object in case of success.
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
# A query against a server root indicates that all resources within the
|
|
305
|
+
# server SHALL be included, subject to filtering.
|
|
306
|
+
# https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.4.2.1
|
|
307
|
+
|
|
308
|
+
if not check_request_payload:
|
|
309
|
+
payload = search_request
|
|
310
|
+
|
|
311
|
+
else:
|
|
312
|
+
payload = (
|
|
313
|
+
search_request.model_dump(
|
|
314
|
+
exclude_unset=True, scim_ctx=Context.RESOURCE_QUERY_REQUEST
|
|
315
|
+
)
|
|
316
|
+
if search_request
|
|
317
|
+
else None
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
response = self.client.get("/", params=payload)
|
|
321
|
+
|
|
322
|
+
return self.check_response(
|
|
323
|
+
response,
|
|
324
|
+
self.QUERY_RESPONSE_STATUS_CODES if check_status_code else None,
|
|
325
|
+
ListResponse[Union[self.resource_types]]
|
|
326
|
+
if check_response_payload
|
|
327
|
+
else None,
|
|
328
|
+
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def search(
|
|
332
|
+
self,
|
|
333
|
+
search_request: Optional[SearchRequest] = None,
|
|
334
|
+
check_request_payload: bool = True,
|
|
335
|
+
check_response_payload: bool = True,
|
|
336
|
+
check_status_code: bool = True,
|
|
337
|
+
**kwargs,
|
|
338
|
+
) -> Union[AnyResource, ListResponse[AnyResource], Error, Dict]:
|
|
339
|
+
"""Perform a POST search request to read all available resources, as
|
|
340
|
+
defined in :rfc:`RFC7644 §3.4.3 <7644#section-3.4.3>`.
|
|
341
|
+
|
|
342
|
+
:param resource_types: Resource type or union of types expected
|
|
343
|
+
to be read from the response.
|
|
344
|
+
:param search_request: An object detailing the search query parameters.
|
|
345
|
+
:param check_request_payload: If :data:`False`,
|
|
346
|
+
:code:`search_request` is expected to be a dict that will be passed as-is in the request.
|
|
347
|
+
:param check_response_payload: Whether to validate that the response payload is valid.
|
|
348
|
+
If set, the raw payload will be returned.
|
|
349
|
+
:param check_status_code: Whether to validate that the response status code is valid.
|
|
350
|
+
:param kwargs: Additional parameters passed to the underlying
|
|
351
|
+
HTTP request library.
|
|
352
|
+
|
|
353
|
+
:return:
|
|
354
|
+
- A :class:`~scim2_models.Error` object in case of error.
|
|
355
|
+
- A :class:`~scim2_models.ListResponse[resource_type]` object in case of success.
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
if not check_request_payload:
|
|
359
|
+
payload = search_request
|
|
360
|
+
|
|
361
|
+
else:
|
|
362
|
+
payload = (
|
|
363
|
+
search_request.model_dump(
|
|
364
|
+
exclude_unset=True, scim_ctx=Context.RESOURCE_QUERY_RESPONSE
|
|
365
|
+
)
|
|
366
|
+
if search_request
|
|
367
|
+
else None
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
response = self.client.post("/.search", json=payload)
|
|
371
|
+
|
|
372
|
+
return self.check_response(
|
|
373
|
+
response,
|
|
374
|
+
self.SEARCH_RESPONSE_STATUS_CODES if check_status_code else None,
|
|
375
|
+
ListResponse[Union[self.resource_types]]
|
|
376
|
+
if check_response_payload
|
|
377
|
+
else None,
|
|
378
|
+
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def delete(
|
|
382
|
+
self, resource_type: Type, id: str, check_status_code: bool = True, **kwargs
|
|
383
|
+
) -> Optional[Union[Error, Dict]]:
|
|
384
|
+
"""Perform a DELETE request to create, as defined in :rfc:`RFC7644 §3.6
|
|
385
|
+
<7644#section-3.6>`.
|
|
386
|
+
|
|
387
|
+
:param check_status_code: Whether to validate that the response status code is valid.
|
|
388
|
+
:param kwargs: Additional parameters passed to the underlying
|
|
389
|
+
HTTP request library.
|
|
390
|
+
|
|
391
|
+
:return:
|
|
392
|
+
- A :class:`~scim2_models.Error` object in case of error.
|
|
393
|
+
- :data:`None` in case of success.
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
self.check_resource_type(resource_type)
|
|
397
|
+
url = self.resource_endpoint(resource_type) + f"/{id}"
|
|
398
|
+
response = self.client.delete(url, **kwargs)
|
|
399
|
+
|
|
400
|
+
return self.check_response(
|
|
401
|
+
response, self.DELETION_RESPONSE_STATUS_CODES if check_status_code else None
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
def replace(
|
|
405
|
+
self,
|
|
406
|
+
resource: Union[AnyResource, Dict],
|
|
407
|
+
check_request_payload: bool = True,
|
|
408
|
+
check_response_payload: bool = True,
|
|
409
|
+
check_status_code: bool = True,
|
|
410
|
+
**kwargs,
|
|
411
|
+
) -> Union[AnyResource, Error, Dict]:
|
|
412
|
+
"""Perform a PUT request to replace a resource, as defined in
|
|
413
|
+
:rfc:`RFC7644 §3.5.1 <7644#section-3.5.1>`.
|
|
414
|
+
|
|
415
|
+
:param resource: The new resource to replace.
|
|
416
|
+
If is a :data:`dict`, the resource type will be guessed from the schema.
|
|
417
|
+
:param check_request_payload: If :data:`False`,
|
|
418
|
+
:code:`resource` is expected to be a dict that will be passed as-is in the request.
|
|
419
|
+
:param check_response_payload: Whether to validate that the response payload is valid.
|
|
420
|
+
If set, the raw payload will be returned.
|
|
421
|
+
:param check_status_code: Whether to validate that the response status code is valid.
|
|
422
|
+
:param kwargs: Additional parameters passed to the underlying
|
|
423
|
+
HTTP request library.
|
|
424
|
+
|
|
425
|
+
:return:
|
|
426
|
+
- An :class:`~scim2_models.Error` object in case of error.
|
|
427
|
+
- The updated object as returned by the server in case of success.
|
|
428
|
+
"""
|
|
429
|
+
|
|
430
|
+
if not check_request_payload:
|
|
431
|
+
payload = resource
|
|
432
|
+
url = kwargs.pop("url", None)
|
|
433
|
+
|
|
434
|
+
else:
|
|
435
|
+
if isinstance(resource, Resource):
|
|
436
|
+
resource_type = resource.__class__
|
|
437
|
+
|
|
438
|
+
else:
|
|
439
|
+
resource_type = Resource.get_by_payload(self.resource_types, resource)
|
|
440
|
+
if not resource_type:
|
|
441
|
+
raise SCIMClientError(
|
|
442
|
+
None, "Cannot guess resource type from the payload"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
resource = resource_type.model_validate(resource)
|
|
446
|
+
|
|
447
|
+
self.check_resource_type(resource_type)
|
|
448
|
+
|
|
449
|
+
if not resource.id:
|
|
450
|
+
raise SCIMClientError(None, "Resource must have an id")
|
|
451
|
+
|
|
452
|
+
payload = resource.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST)
|
|
453
|
+
url = kwargs.pop(
|
|
454
|
+
"url", self.resource_endpoint(resource.__class__) + f"/{resource.id}"
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
response = self.client.put(url, json=payload, **kwargs)
|
|
458
|
+
|
|
459
|
+
return self.check_response(
|
|
460
|
+
response,
|
|
461
|
+
self.REPLACEMENT_RESPONSE_STATUS_CODES if check_status_code else None,
|
|
462
|
+
resource.__class__
|
|
463
|
+
if check_request_payload and check_response_payload
|
|
464
|
+
else None,
|
|
465
|
+
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def modify(
|
|
469
|
+
self, resource: Union[AnyResource, Dict], op: PatchOp, **kwargs
|
|
470
|
+
) -> Optional[Union[AnyResource, Dict]]:
|
|
471
|
+
raise NotImplementedError()
|
|
@@ -1,377 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import json.decoder
|
|
3
|
-
from typing import List
|
|
4
|
-
from typing import Optional
|
|
5
|
-
from typing import Tuple
|
|
6
|
-
from typing import Type
|
|
7
|
-
from typing import Union
|
|
8
|
-
|
|
9
|
-
from httpx import Client
|
|
10
|
-
from httpx import Response
|
|
11
|
-
from pydantic import ValidationError
|
|
12
|
-
from scim2_models import AnyResource
|
|
13
|
-
from scim2_models import Context
|
|
14
|
-
from scim2_models import Error
|
|
15
|
-
from scim2_models import ListResponse
|
|
16
|
-
from scim2_models import PatchOp
|
|
17
|
-
from scim2_models import SearchRequest
|
|
18
|
-
|
|
19
|
-
from .errors import UnexpectedContentFormat
|
|
20
|
-
from .errors import UnexpectedContentType
|
|
21
|
-
from .errors import UnexpectedStatusCode
|
|
22
|
-
|
|
23
|
-
BASE_HEADERS = {
|
|
24
|
-
"Accept": "application/scim+json",
|
|
25
|
-
"Content-Type": "application/scim+json",
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class SCIMClient:
|
|
30
|
-
"""An object that perform SCIM requests and validate responses."""
|
|
31
|
-
|
|
32
|
-
def __init__(self, client: Client, resource_types: Optional[Tuple[Type]] = None):
|
|
33
|
-
self.client = client
|
|
34
|
-
self.resource_types = resource_types or ()
|
|
35
|
-
|
|
36
|
-
def check_resource_type(self, resource_type):
|
|
37
|
-
if resource_type not in self.resource_types:
|
|
38
|
-
raise ValueError(f"Unknown resource type: '{resource_type}'")
|
|
39
|
-
|
|
40
|
-
def resource_endpoint(self, resource_type: Type):
|
|
41
|
-
return f"/{resource_type.__name__}s"
|
|
42
|
-
|
|
43
|
-
def check_response(
|
|
44
|
-
self,
|
|
45
|
-
response: Response,
|
|
46
|
-
expected_status_codes: List[int],
|
|
47
|
-
expected_type: Optional[Type] = None,
|
|
48
|
-
scim_ctx: Optional[Context] = None,
|
|
49
|
-
):
|
|
50
|
-
if response.status_code not in expected_status_codes:
|
|
51
|
-
raise UnexpectedStatusCode(response)
|
|
52
|
-
|
|
53
|
-
# Interoperability considerations: The "application/scim+json" media
|
|
54
|
-
# type is intended to identify JSON structure data that conforms to
|
|
55
|
-
# the SCIM protocol and schema specifications. Older versions of
|
|
56
|
-
# SCIM are known to informally use "application/json".
|
|
57
|
-
# https://datatracker.ietf.org/doc/html/rfc7644.html#section-8.1
|
|
58
|
-
|
|
59
|
-
expected_response_content_types = ("application/scim+json", "application/json")
|
|
60
|
-
if response.headers.get("content-type") not in expected_response_content_types:
|
|
61
|
-
raise UnexpectedContentType(response)
|
|
62
|
-
|
|
63
|
-
# In addition to returning an HTTP response code, implementers MUST return
|
|
64
|
-
# the errors in the body of the response in a JSON format
|
|
65
|
-
# https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
|
|
66
|
-
|
|
67
|
-
if response.status_code in (204, 205):
|
|
68
|
-
response_payload = None
|
|
69
|
-
|
|
70
|
-
else:
|
|
71
|
-
try:
|
|
72
|
-
response_payload = response.json()
|
|
73
|
-
except json.decoder.JSONDecodeError as exc:
|
|
74
|
-
raise UnexpectedContentFormat(response) from exc
|
|
75
|
-
|
|
76
|
-
try:
|
|
77
|
-
return Error.model_validate(response_payload)
|
|
78
|
-
except ValidationError:
|
|
79
|
-
pass
|
|
80
|
-
|
|
81
|
-
if expected_type:
|
|
82
|
-
return expected_type.model_validate(response_payload, scim_ctx=scim_ctx)
|
|
83
|
-
|
|
84
|
-
return response_payload
|
|
85
|
-
|
|
86
|
-
def create(self, resource: AnyResource, **kwargs) -> Union[AnyResource, Error]:
|
|
87
|
-
"""Perform a POST request to create, as defined in :rfc:`RFC7644 §3.3
|
|
88
|
-
<7644#section-3.3>`.
|
|
89
|
-
|
|
90
|
-
:param resource: The resource to create
|
|
91
|
-
:param kwargs: Additional parameters passed to the underlying HTTP request
|
|
92
|
-
library.
|
|
93
|
-
|
|
94
|
-
:return:
|
|
95
|
-
- An :class:`~scim2_models.Error` object in case of error.
|
|
96
|
-
- The created object as returned by the server in case of success.
|
|
97
|
-
"""
|
|
98
|
-
|
|
99
|
-
self.check_resource_type(resource.__class__)
|
|
100
|
-
url = self.resource_endpoint(resource.__class__)
|
|
101
|
-
dump = resource.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST)
|
|
102
|
-
response = self.client.post(url, json=dump, **kwargs)
|
|
103
|
-
|
|
104
|
-
expected_status_codes = [
|
|
105
|
-
# Resource creation HTTP codes defined at:
|
|
106
|
-
# https://datatracker.ietf.org/doc/html/rfc7644#section-3.3
|
|
107
|
-
201,
|
|
108
|
-
409,
|
|
109
|
-
# Default HTTP codes defined at:
|
|
110
|
-
# https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
|
|
111
|
-
307,
|
|
112
|
-
308,
|
|
113
|
-
400,
|
|
114
|
-
401,
|
|
115
|
-
403,
|
|
116
|
-
404,
|
|
117
|
-
500,
|
|
118
|
-
]
|
|
119
|
-
return self.check_response(
|
|
120
|
-
response,
|
|
121
|
-
expected_status_codes,
|
|
122
|
-
resource.__class__,
|
|
123
|
-
scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
def query(
|
|
127
|
-
self,
|
|
128
|
-
resource_type: Type,
|
|
129
|
-
id: Optional[str] = None,
|
|
130
|
-
search_request: Optional[SearchRequest] = None,
|
|
131
|
-
**kwargs,
|
|
132
|
-
) -> Union[AnyResource, ListResponse[AnyResource], Error]:
|
|
133
|
-
"""Perform a GET request to read resources, as defined in :rfc:`RFC7644
|
|
134
|
-
§3.4.2 <7644#section-3.4.2>`.
|
|
135
|
-
|
|
136
|
-
- If `id` is not :data:`None`, the resource with the exact id will be reached.
|
|
137
|
-
- If `id` is :data:`None`, all the resources with the given type will be reached.
|
|
138
|
-
|
|
139
|
-
:param resource_type: A :class:`~scim2_models.Resource` subtype or :data:`None`
|
|
140
|
-
:param id: The SCIM id of an object to get, or :data:`None`
|
|
141
|
-
:param search_request: An object detailing the search query parameters.
|
|
142
|
-
:param kwargs: Additional parameters passed to the underlying HTTP request library.
|
|
143
|
-
|
|
144
|
-
:return:
|
|
145
|
-
- A :class:`~scim2_models.Error` object in case of error.
|
|
146
|
-
- A `resource_type` object in case of success if `id` is not :data:`None`
|
|
147
|
-
- A :class:`~scim2_models.ListResponse[resource_type]` object in case of success if `id` is :data:`None`
|
|
148
|
-
"""
|
|
149
|
-
|
|
150
|
-
self.check_resource_type(resource_type)
|
|
151
|
-
payload = (
|
|
152
|
-
search_request.model_dump(
|
|
153
|
-
exclude_unset=True,
|
|
154
|
-
scim_ctx=Context.RESOURCE_QUERY_REQUEST,
|
|
155
|
-
)
|
|
156
|
-
if search_request
|
|
157
|
-
else None
|
|
158
|
-
)
|
|
159
|
-
|
|
160
|
-
if not id:
|
|
161
|
-
expected_type = ListResponse[resource_type]
|
|
162
|
-
url = self.resource_endpoint(resource_type)
|
|
163
|
-
|
|
164
|
-
else:
|
|
165
|
-
expected_type = resource_type
|
|
166
|
-
url = self.resource_endpoint(resource_type) + f"/{id}"
|
|
167
|
-
|
|
168
|
-
expected_status_codes = [
|
|
169
|
-
# Resource querying HTTP codes defined at:
|
|
170
|
-
# https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2
|
|
171
|
-
200,
|
|
172
|
-
400,
|
|
173
|
-
# Default HTTP codes defined at:
|
|
174
|
-
# https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
|
|
175
|
-
307,
|
|
176
|
-
308,
|
|
177
|
-
401,
|
|
178
|
-
403,
|
|
179
|
-
404,
|
|
180
|
-
500,
|
|
181
|
-
]
|
|
182
|
-
response = self.client.get(url, params=payload, **kwargs)
|
|
183
|
-
return self.check_response(
|
|
184
|
-
response,
|
|
185
|
-
expected_status_codes,
|
|
186
|
-
expected_type,
|
|
187
|
-
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
def query_all(
|
|
191
|
-
self,
|
|
192
|
-
search_request: Optional[SearchRequest] = None,
|
|
193
|
-
**kwargs,
|
|
194
|
-
) -> Union[AnyResource, ListResponse[AnyResource], Error]:
|
|
195
|
-
"""Perform a GET request to read all available resources, as defined in
|
|
196
|
-
:rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>`.
|
|
197
|
-
|
|
198
|
-
:param search_request: An object detailing the search query parameters.
|
|
199
|
-
:param kwargs: Additional parameters passed to the underlying
|
|
200
|
-
HTTP request library.
|
|
201
|
-
|
|
202
|
-
:return:
|
|
203
|
-
- A :class:`~scim2_models.Error` object in case of error.
|
|
204
|
-
- A :class:`~scim2_models.ListResponse[resource_type]` object in case of success.
|
|
205
|
-
"""
|
|
206
|
-
|
|
207
|
-
# A query against a server root indicates that all resources within the
|
|
208
|
-
# server SHALL be included, subject to filtering.
|
|
209
|
-
# https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.4.2.1
|
|
210
|
-
|
|
211
|
-
payload = (
|
|
212
|
-
search_request.model_dump(
|
|
213
|
-
exclude_unset=True, scim_ctx=Context.RESOURCE_QUERY_REQUEST
|
|
214
|
-
)
|
|
215
|
-
if search_request
|
|
216
|
-
else None
|
|
217
|
-
)
|
|
218
|
-
response = self.client.get("/", params=payload)
|
|
219
|
-
|
|
220
|
-
expected_status_codes = [
|
|
221
|
-
# Resource querying HTTP codes defined at:
|
|
222
|
-
# https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2
|
|
223
|
-
200,
|
|
224
|
-
400,
|
|
225
|
-
# Default HTTP codes defined at:
|
|
226
|
-
# https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
|
|
227
|
-
307,
|
|
228
|
-
308,
|
|
229
|
-
401,
|
|
230
|
-
403,
|
|
231
|
-
404,
|
|
232
|
-
500,
|
|
233
|
-
501,
|
|
234
|
-
]
|
|
235
|
-
|
|
236
|
-
return self.check_response(
|
|
237
|
-
response,
|
|
238
|
-
expected_status_codes,
|
|
239
|
-
ListResponse[Union[self.resource_types]],
|
|
240
|
-
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
|
|
241
|
-
)
|
|
242
|
-
|
|
243
|
-
def search(
|
|
244
|
-
self,
|
|
245
|
-
search_request: Optional[SearchRequest] = None,
|
|
246
|
-
**kwargs,
|
|
247
|
-
) -> Union[AnyResource, ListResponse[AnyResource], Error]:
|
|
248
|
-
"""Perform a POST search request to read all available resources, as
|
|
249
|
-
defined in :rfc:`RFC7644 §3.4.3 <7644#section-3.4.3>`.
|
|
250
|
-
|
|
251
|
-
:param resource_types: Resource type or union of types expected
|
|
252
|
-
to be read from the response.
|
|
253
|
-
:param search_request: An object detailing the search query parameters.
|
|
254
|
-
:param kwargs: Additional parameters passed to the underlying
|
|
255
|
-
HTTP request library.
|
|
256
|
-
|
|
257
|
-
:return:
|
|
258
|
-
- A :class:`~scim2_models.Error` object in case of error.
|
|
259
|
-
- A :class:`~scim2_models.ListResponse[resource_type]` object in case of success.
|
|
260
|
-
"""
|
|
261
|
-
|
|
262
|
-
payload = (
|
|
263
|
-
search_request.model_dump(
|
|
264
|
-
exclude_unset=True, scim_ctx=Context.RESOURCE_QUERY_RESPONSE
|
|
265
|
-
)
|
|
266
|
-
if search_request
|
|
267
|
-
else None
|
|
268
|
-
)
|
|
269
|
-
response = self.client.post("/.search", params=payload)
|
|
270
|
-
|
|
271
|
-
expected_status_codes = [
|
|
272
|
-
# Resource querying HTTP codes defined at:
|
|
273
|
-
# https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.3
|
|
274
|
-
200,
|
|
275
|
-
# Default HTTP codes defined at:
|
|
276
|
-
# https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
|
|
277
|
-
307,
|
|
278
|
-
308,
|
|
279
|
-
400,
|
|
280
|
-
401,
|
|
281
|
-
403,
|
|
282
|
-
404,
|
|
283
|
-
409,
|
|
284
|
-
413,
|
|
285
|
-
500,
|
|
286
|
-
501,
|
|
287
|
-
]
|
|
288
|
-
return self.check_response(
|
|
289
|
-
response,
|
|
290
|
-
expected_status_codes,
|
|
291
|
-
ListResponse[Union[self.resource_types]],
|
|
292
|
-
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
|
|
293
|
-
)
|
|
294
|
-
|
|
295
|
-
def delete(self, resource_type: Type, id: str, **kwargs) -> Optional[Error]:
|
|
296
|
-
"""Perform a DELETE request to create, as defined in :rfc:`RFC7644 §3.6
|
|
297
|
-
<7644#section-3.6>`.
|
|
298
|
-
|
|
299
|
-
:param kwargs: Additional parameters passed to the underlying
|
|
300
|
-
HTTP request library.
|
|
301
|
-
|
|
302
|
-
:return:
|
|
303
|
-
- A :class:`~scim2_models.Error` object in case of error.
|
|
304
|
-
- :data:`None` in case of success.
|
|
305
|
-
"""
|
|
306
|
-
|
|
307
|
-
self.check_resource_type(resource_type)
|
|
308
|
-
url = self.resource_endpoint(resource_type) + f"/{id}"
|
|
309
|
-
response = self.client.delete(url, **kwargs)
|
|
310
|
-
|
|
311
|
-
expected_status_codes = [
|
|
312
|
-
# Resource deletion HTTP codes defined at:
|
|
313
|
-
# https://datatracker.ietf.org/doc/html/rfc7644#section-3.6
|
|
314
|
-
204,
|
|
315
|
-
# Default HTTP codes defined at:
|
|
316
|
-
# https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
|
|
317
|
-
307,
|
|
318
|
-
308,
|
|
319
|
-
400,
|
|
320
|
-
401,
|
|
321
|
-
403,
|
|
322
|
-
404,
|
|
323
|
-
412,
|
|
324
|
-
500,
|
|
325
|
-
501,
|
|
326
|
-
]
|
|
327
|
-
return self.check_response(response, expected_status_codes)
|
|
328
|
-
|
|
329
|
-
def replace(self, resource: AnyResource, **kwargs) -> Union[AnyResource, Error]:
|
|
330
|
-
"""Perform a PUT request to replace a resource, as defined in
|
|
331
|
-
:rfc:`RFC7644 §3.5.1 <7644#section-3.5.1>`.
|
|
332
|
-
|
|
333
|
-
:param resource: The new state of the resource to replace.
|
|
334
|
-
:param kwargs: Additional parameters passed to the underlying
|
|
335
|
-
HTTP request library.
|
|
336
|
-
|
|
337
|
-
:return:
|
|
338
|
-
- An :class:`~scim2_models.Error` object in case of error.
|
|
339
|
-
- The updated object as returned by the server in case of success.
|
|
340
|
-
"""
|
|
341
|
-
|
|
342
|
-
self.check_resource_type(resource.__class__)
|
|
343
|
-
if not resource.id:
|
|
344
|
-
raise Exception("Resource must have an id")
|
|
345
|
-
|
|
346
|
-
dump = resource.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST)
|
|
347
|
-
url = self.resource_endpoint(resource.__class__) + f"/{resource.id}"
|
|
348
|
-
response = self.client.put(url, json=dump, **kwargs)
|
|
349
|
-
|
|
350
|
-
expected_status_codes = [
|
|
351
|
-
# Resource querying HTTP codes defined at:
|
|
352
|
-
# https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2
|
|
353
|
-
200,
|
|
354
|
-
# Default HTTP codes defined at:
|
|
355
|
-
# https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
|
|
356
|
-
307,
|
|
357
|
-
308,
|
|
358
|
-
400,
|
|
359
|
-
401,
|
|
360
|
-
403,
|
|
361
|
-
404,
|
|
362
|
-
409,
|
|
363
|
-
412,
|
|
364
|
-
500,
|
|
365
|
-
501,
|
|
366
|
-
]
|
|
367
|
-
return self.check_response(
|
|
368
|
-
response,
|
|
369
|
-
expected_status_codes,
|
|
370
|
-
resource.__class__,
|
|
371
|
-
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
|
|
372
|
-
)
|
|
373
|
-
|
|
374
|
-
def modify(
|
|
375
|
-
self, resource: AnyResource, op: PatchOp, **kwargs
|
|
376
|
-
) -> Optional[AnyResource]:
|
|
377
|
-
raise NotImplementedError()
|
|
File without changes
|
|
File without changes
|