scim2-client 0.1.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.
@@ -0,0 +1,13 @@
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
+ ]
scim2_client/client.py ADDED
@@ -0,0 +1,352 @@
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 Error
14
+ from scim2_models import ListResponse
15
+ from scim2_models import PatchOp
16
+ from scim2_models import SearchRequest
17
+
18
+ from .errors import UnexpectedContentFormat
19
+ from .errors import UnexpectedContentType
20
+ from .errors import UnexpectedStatusCode
21
+
22
+ BASE_HEADERS = {
23
+ "Accept": "application/scim+json",
24
+ "Content-Type": "application/scim+json",
25
+ }
26
+
27
+
28
+ class SCIMClient:
29
+ """An object that perform SCIM requests and validate responses."""
30
+
31
+ def __init__(self, client: Client, resource_types: Optional[Tuple[Type]] = None):
32
+ self.client = client
33
+ self.resource_types = resource_types or ()
34
+
35
+ def check_resource_type(self, resource_type):
36
+ if resource_type not in self.resource_types:
37
+ raise ValueError(f"Unknown resource type: '{resource_type}'")
38
+
39
+ def resource_endpoint(self, resource_type: Type):
40
+ return f"/{resource_type.__name__}s"
41
+
42
+ def check_response(
43
+ self,
44
+ response: Response,
45
+ expected_status_codes: List[int],
46
+ expected_type: Optional[Type] = None,
47
+ ):
48
+ if response.status_code not in expected_status_codes:
49
+ raise UnexpectedStatusCode(response)
50
+
51
+ # Interoperability considerations: The "application/scim+json" media
52
+ # type is intended to identify JSON structure data that conforms to
53
+ # the SCIM protocol and schema specifications. Older versions of
54
+ # SCIM are known to informally use "application/json".
55
+ # https://datatracker.ietf.org/doc/html/rfc7644.html#section-8.1
56
+
57
+ expected_response_content_types = ("application/scim+json", "application/json")
58
+ if response.headers.get("content-type") not in expected_response_content_types:
59
+ raise UnexpectedContentType(response)
60
+
61
+ # In addition to returning an HTTP response code, implementers MUST return
62
+ # the errors in the body of the response in a JSON format
63
+ # https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
64
+
65
+ if response.status_code in (204, 205):
66
+ response_payload = None
67
+
68
+ else:
69
+ try:
70
+ response_payload = response.json()
71
+ except json.decoder.JSONDecodeError as exc:
72
+ raise UnexpectedContentFormat(response) from exc
73
+
74
+ try:
75
+ return Error.model_validate(response_payload)
76
+ except ValidationError:
77
+ pass
78
+
79
+ if expected_type:
80
+ return expected_type.model_validate(response_payload)
81
+ return response_payload
82
+
83
+ def create(self, resource: AnyResource, **kwargs) -> Union[AnyResource, Error]:
84
+ """Perform a POST request to create, as defined in :rfc:`RFC7644 §3.3
85
+ <7644#section-3.3>`.
86
+
87
+ :param resource: The resource to create
88
+ :param kwargs: Additional parameters passed to the underlying HTTP request
89
+ library.
90
+
91
+ :return:
92
+ - An :class:`~scim2_models.Error` object in case of error.
93
+ - The created object as returned by the server in case of success.
94
+ """
95
+
96
+ self.check_resource_type(resource.__class__)
97
+ url = self.resource_endpoint(resource.__class__)
98
+ dump = resource.model_dump(exclude_none=True, by_alias=True, mode="json")
99
+ response = self.client.post(url, json=dump, **kwargs)
100
+
101
+ expected_status_codes = [
102
+ # Resource creation HTTP codes defined at:
103
+ # https://datatracker.ietf.org/doc/html/rfc7644#section-3.3
104
+ 201,
105
+ 409,
106
+ # Default HTTP codes defined at:
107
+ # https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
108
+ 307,
109
+ 308,
110
+ 400,
111
+ 401,
112
+ 403,
113
+ 404,
114
+ 500,
115
+ ]
116
+ return self.check_response(response, expected_status_codes, resource.__class__)
117
+
118
+ def query(
119
+ self,
120
+ resource_type: Type,
121
+ id: Optional[str] = None,
122
+ search_request: Optional[SearchRequest] = None,
123
+ **kwargs,
124
+ ) -> Union[AnyResource, ListResponse[AnyResource], Error]:
125
+ """Perform a GET request to read resources, as defined in :rfc:`RFC7644
126
+ §3.4.2 <7644#section-3.4.2>`.
127
+
128
+ - If `id` is not :data:`None`, the resource with the exact id will be reached.
129
+ - If `id` is :data:`None`, all the resources with the given type will be reached.
130
+
131
+ :param resource_type: A :class:`~scim2_models.Resource` subtype or :data:`None`
132
+ :param id: The SCIM id of an object to get, or :data:`None`
133
+ :param search_request: An object detailing the search query parameters.
134
+ :param kwargs: Additional parameters passed to the underlying HTTP request library.
135
+
136
+ :return:
137
+ - A :class:`~scim2_models.Error` object in case of error.
138
+ - A `resource_type` object in case of success if `id` is not :data:`None`
139
+ - A :class:`~scim2_models.ListResponse[resource_type]` object in case of success if `id` is :data:`None`
140
+ """
141
+
142
+ self.check_resource_type(resource_type)
143
+ payload = (
144
+ search_request.model_dump(
145
+ by_alias=True, exclude_none=True, exclude_unset=True, mode="json"
146
+ )
147
+ if search_request
148
+ else None
149
+ )
150
+
151
+ if not id:
152
+ expected_type = ListResponse[resource_type]
153
+ url = self.resource_endpoint(resource_type)
154
+
155
+ else:
156
+ expected_type = resource_type
157
+ url = self.resource_endpoint(resource_type) + f"/{id}"
158
+
159
+ expected_status_codes = [
160
+ # Resource querying HTTP codes defined at:
161
+ # https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2
162
+ 200,
163
+ 400,
164
+ # Default HTTP codes defined at:
165
+ # https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
166
+ 307,
167
+ 308,
168
+ 401,
169
+ 403,
170
+ 404,
171
+ 500,
172
+ ]
173
+ response = self.client.get(url, params=payload, **kwargs)
174
+ return self.check_response(response, expected_status_codes, expected_type)
175
+
176
+ def query_all(
177
+ self,
178
+ search_request: Optional[SearchRequest] = None,
179
+ **kwargs,
180
+ ) -> Union[AnyResource, ListResponse[AnyResource], Error]:
181
+ """Perform a GET request to read all available resources, as defined in
182
+ :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>`.
183
+
184
+ :param search_request: An object detailing the search query parameters.
185
+ :param kwargs: Additional parameters passed to the underlying
186
+ HTTP request library.
187
+
188
+ :return:
189
+ - A :class:`~scim2_models.Error` object in case of error.
190
+ - A :class:`~scim2_models.ListResponse[resource_type]` object in case of success.
191
+ """
192
+
193
+ # A query against a server root indicates that all resources within the
194
+ # server SHALL be included, subject to filtering.
195
+ # https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.4.2.1
196
+
197
+ payload = (
198
+ search_request.model_dump(
199
+ by_alias=True, exclude_none=True, exclude_unset=True, mode="json"
200
+ )
201
+ if search_request
202
+ else None
203
+ )
204
+ response = self.client.get("/", params=payload)
205
+
206
+ expected_status_codes = [
207
+ # Resource querying HTTP codes defined at:
208
+ # https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2
209
+ 200,
210
+ 400,
211
+ # Default HTTP codes defined at:
212
+ # https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
213
+ 307,
214
+ 308,
215
+ 401,
216
+ 403,
217
+ 404,
218
+ 500,
219
+ 501,
220
+ ]
221
+
222
+ return self.check_response(
223
+ response, expected_status_codes, ListResponse[Union[self.resource_types]]
224
+ )
225
+
226
+ def search(
227
+ self,
228
+ search_request: Optional[SearchRequest] = None,
229
+ **kwargs,
230
+ ) -> Union[AnyResource, ListResponse[AnyResource], Error]:
231
+ """Perform a POST search request to read all available resources, as
232
+ defined in :rfc:`RFC7644 §3.4.3 <7644#section-3.4.3>`.
233
+
234
+ :param resource_types: Resource type or union of types expected
235
+ to be read from the response.
236
+ :param search_request: An object detailing the search query parameters.
237
+ :param kwargs: Additional parameters passed to the underlying
238
+ HTTP request library.
239
+
240
+ :return:
241
+ - A :class:`~scim2_models.Error` object in case of error.
242
+ - A :class:`~scim2_models.ListResponse[resource_type]` object in case of success.
243
+ """
244
+
245
+ payload = (
246
+ search_request.model_dump(
247
+ by_alias=True, exclude_none=True, exclude_unset=True, mode="json"
248
+ )
249
+ if search_request
250
+ else None
251
+ )
252
+ response = self.client.post("/.search", params=payload)
253
+
254
+ expected_status_codes = [
255
+ # Resource querying HTTP codes defined at:
256
+ # https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.3
257
+ 200,
258
+ # Default HTTP codes defined at:
259
+ # https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
260
+ 307,
261
+ 308,
262
+ 400,
263
+ 401,
264
+ 403,
265
+ 404,
266
+ 409,
267
+ 413,
268
+ 500,
269
+ 501,
270
+ ]
271
+ return self.check_response(
272
+ response, expected_status_codes, ListResponse[Union[self.resource_types]]
273
+ )
274
+
275
+ def delete(self, resource_type: Type, id: str, **kwargs) -> Optional[Error]:
276
+ """Perform a DELETE request to create, as defined in :rfc:`RFC7644 §3.6
277
+ <7644#section-3.6>`.
278
+
279
+ :param kwargs: Additional parameters passed to the underlying
280
+ HTTP request library.
281
+
282
+ :return:
283
+ - A :class:`~scim2_models.Error` object in case of error.
284
+ - :data:`None` in case of success.
285
+ """
286
+
287
+ self.check_resource_type(resource_type)
288
+ url = self.resource_endpoint(resource_type) + f"/{id}"
289
+ response = self.client.delete(url, **kwargs)
290
+
291
+ expected_status_codes = [
292
+ # Resource deletion HTTP codes defined at:
293
+ # https://datatracker.ietf.org/doc/html/rfc7644#section-3.6
294
+ 204,
295
+ # Default HTTP codes defined at:
296
+ # https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
297
+ 307,
298
+ 308,
299
+ 400,
300
+ 401,
301
+ 403,
302
+ 404,
303
+ 412,
304
+ 500,
305
+ 501,
306
+ ]
307
+ return self.check_response(response, expected_status_codes)
308
+
309
+ def replace(self, resource: AnyResource, **kwargs) -> Union[AnyResource, Error]:
310
+ """Perform a PUT request to replace a resource, as defined in
311
+ :rfc:`RFC7644 §3.5.1 <7644#section-3.5.1>`.
312
+
313
+ :param resource: The new state of the resource to replace.
314
+ :param kwargs: Additional parameters passed to the underlying
315
+ HTTP request library.
316
+
317
+ :return:
318
+ - An :class:`~scim2_models.Error` object in case of error.
319
+ - The updated object as returned by the server in case of success.
320
+ """
321
+
322
+ self.check_resource_type(resource.__class__)
323
+ if not resource.id:
324
+ raise Exception("Resource must have an id")
325
+
326
+ dump = resource.model_dump(exclude_none=True, by_alias=True, mode="json")
327
+ url = self.resource_endpoint(resource.__class__) + f"/{resource.id}"
328
+ response = self.client.put(url, json=dump, **kwargs)
329
+
330
+ expected_status_codes = [
331
+ # Resource querying HTTP codes defined at:
332
+ # https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2
333
+ 200,
334
+ # Default HTTP codes defined at:
335
+ # https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.12
336
+ 307,
337
+ 308,
338
+ 400,
339
+ 401,
340
+ 403,
341
+ 404,
342
+ 409,
343
+ 412,
344
+ 500,
345
+ 501,
346
+ ]
347
+ return self.check_response(response, expected_status_codes, resource.__class__)
348
+
349
+ def modify(
350
+ self, resource: AnyResource, op: PatchOp, **kwargs
351
+ ) -> Optional[AnyResource]:
352
+ raise NotImplementedError()
scim2_client/errors.py ADDED
@@ -0,0 +1,43 @@
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)
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.1
2
+ Name: scim2-client
3
+ Version: 0.1.0
4
+ Summary: Pythonically build SCIM requests and parse SCIM responses
5
+ License: MIT
6
+ Keywords: scim,scim2,provisioning,httpx,api
7
+ Author: Yaal Coop
8
+ Author-email: contact@yaal.coop
9
+ Requires-Python: >=3.9,<4.0
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: Implementation :: CPython
22
+ Requires-Dist: httpx (>=0.27.0,<0.28.0)
23
+ Requires-Dist: scim2-models (>=0.1.0,<0.2.0)
24
+ Description-Content-Type: text/markdown
25
+
26
+ # scim2-client
27
+
28
+ A SCIM client library built upon [scim2-models](https://scim2-models.readthedocs.io), that pythonically build requests and parse responses, following the [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643.html) and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644.html) specifications.
29
+ ## Installation
30
+
31
+ ```shell
32
+ pip install scim2-client
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ Check the [tutorial](https://scim2-client.readthedocs.io/en/latest/tutorial.html) and the [reference](https://scim2-client.readthedocs.io/en/latest/reference.html) for more details.
38
+
39
+ Here is an example of usage:
40
+
41
+ ```python
42
+ import datetime
43
+ from httpx impont Client
44
+ from scim2_models import User, EnterpriseUserUser, Group, Error
45
+ from scim2_client import SCIMClient
46
+
47
+ client = Client(base_url=f"https://auth.example/scim/v2", headers={"Authorization": "Bearer foobar"})
48
+ scim = SCIMClient(client, resource_types=(User[EnterpriseUser], Group))
49
+
50
+ # Query resources
51
+ user = scim.query(User, "2819c223-7f76-453a-919d-413861904646")
52
+ assert user.user_name == "bjensen@example.com"
53
+ assert user.meta.last_updated == datetime.datetime(
54
+ 2024, 4, 13, 12, 0, 0, tzinfo=datetime.timezone.utc
55
+ )
56
+
57
+ # Update resources
58
+ user.display_name = "Babes Jensen"
59
+ user = scim.replace(user)
60
+ assert user.display_name == "Babes Jensen"
61
+ assert user.meta.last_updated == datetime.datetime(
62
+ 2024, 4, 13, 12, 0, 30, tzinfo=datetime.timezone.utc
63
+ )
64
+
65
+ # Create resources
66
+ response = scim.create(User, "2819c223-7f76-453a-919d-413861904646")
67
+ assert isinstance(response, Error)
68
+ assert response.detail == "One or more of the attribute values are already in use or are reserved."
69
+ ```
70
+
@@ -0,0 +1,6 @@
1
+ scim2_client/__init__.py,sha256=2UNsl6HNtVUv5LVcnmLaCHyT4SqAUUFOIWW2r5XGv6A,338
2
+ scim2_client/client.py,sha256=hLrOaXIzPE_4ZlJKAJNqWOEnFAVEo7ctPuns9qSOJ1E,12635
3
+ scim2_client/errors.py,sha256=uOOAwsD8rDrC8BQbwPid051YyWtexTcS8b7i6QC6-CM,1175
4
+ scim2_client-0.1.0.dist-info/METADATA,sha256=HY_uiLBOOMueWBN_bMbi2rq_G6q6iKfJNoCauSHeavQ,2602
5
+ scim2_client-0.1.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
6
+ scim2_client-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any