scim2-client 0.1.0__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.
|
@@ -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,44 @@
|
|
|
1
|
+
# scim2-client
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
## Installation
|
|
5
|
+
|
|
6
|
+
```shell
|
|
7
|
+
pip install scim2-client
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
Here is an example of usage:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import datetime
|
|
18
|
+
from httpx impont Client
|
|
19
|
+
from scim2_models import User, EnterpriseUserUser, Group, Error
|
|
20
|
+
from scim2_client import SCIMClient
|
|
21
|
+
|
|
22
|
+
client = Client(base_url=f"https://auth.example/scim/v2", headers={"Authorization": "Bearer foobar"})
|
|
23
|
+
scim = SCIMClient(client, resource_types=(User[EnterpriseUser], Group))
|
|
24
|
+
|
|
25
|
+
# Query resources
|
|
26
|
+
user = scim.query(User, "2819c223-7f76-453a-919d-413861904646")
|
|
27
|
+
assert user.user_name == "bjensen@example.com"
|
|
28
|
+
assert user.meta.last_updated == datetime.datetime(
|
|
29
|
+
2024, 4, 13, 12, 0, 0, tzinfo=datetime.timezone.utc
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Update resources
|
|
33
|
+
user.display_name = "Babes Jensen"
|
|
34
|
+
user = scim.replace(user)
|
|
35
|
+
assert user.display_name == "Babes Jensen"
|
|
36
|
+
assert user.meta.last_updated == datetime.datetime(
|
|
37
|
+
2024, 4, 13, 12, 0, 30, tzinfo=datetime.timezone.utc
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Create resources
|
|
41
|
+
response = scim.create(User, "2819c223-7f76-453a-919d-413861904646")
|
|
42
|
+
assert isinstance(response, Error)
|
|
43
|
+
assert response.detail == "One or more of the attribute values are already in use or are reserved."
|
|
44
|
+
```
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry-core"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
[tool.poetry]
|
|
6
|
+
name = "scim2-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Pythonically build SCIM requests and parse SCIM responses"
|
|
9
|
+
authors = ["Yaal Coop <contact@yaal.coop>"]
|
|
10
|
+
license = "MIT"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
keywords = ["scim", "scim2", "provisioning", "httpx", "api"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Programming Language :: Python :: 3.9",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Environment :: Web Environment",
|
|
23
|
+
"Programming Language :: Python",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.poetry.dependencies]
|
|
28
|
+
python = "^3.9"
|
|
29
|
+
httpx = "^0.27.0"
|
|
30
|
+
scim2-models = "^0.1.0"
|
|
31
|
+
|
|
32
|
+
[tool.poetry.group.doc]
|
|
33
|
+
optional = true
|
|
34
|
+
|
|
35
|
+
[tool.poetry.group.dev.dependencies]
|
|
36
|
+
pytest = "^8.2.1"
|
|
37
|
+
pytest-coverage = "^0.0"
|
|
38
|
+
pytest-httpserver = "^1.0.10"
|
|
39
|
+
|
|
40
|
+
[tool.poetry.group.doc.dependencies]
|
|
41
|
+
shibuya = "^2024.5.15"
|
|
42
|
+
sphinx = "^7.3.7"
|
|
43
|
+
myst-parser = "^3.0.1"
|
|
44
|
+
|
|
45
|
+
[tool.coverage.run]
|
|
46
|
+
source = [
|
|
47
|
+
"scim2_client",
|
|
48
|
+
"tests",
|
|
49
|
+
]
|
|
50
|
+
omit = [".tox/*"]
|
|
51
|
+
branch = true
|
|
52
|
+
|
|
53
|
+
[tool.coverage.report]
|
|
54
|
+
exclude_lines = [
|
|
55
|
+
"@pytest.mark.skip",
|
|
56
|
+
"pragma: no cover",
|
|
57
|
+
"raise NotImplementedError",
|
|
58
|
+
"\\.\\.\\.\\s*$", # ignore ellipsis
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint]
|
|
62
|
+
select = [
|
|
63
|
+
"E", # pycodestyle
|
|
64
|
+
"F", # pyflakes
|
|
65
|
+
"I", # isort
|
|
66
|
+
"UP", # pyupgrade
|
|
67
|
+
]
|
|
68
|
+
ignore = [
|
|
69
|
+
"E501", # line-too-long
|
|
70
|
+
"E722", # bare-except
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint.isort]
|
|
74
|
+
force-single-line = true
|
|
75
|
+
|
|
76
|
+
[too.ruff.format]
|
|
77
|
+
docstring-code-format = true
|
|
78
|
+
|
|
79
|
+
[tool.tox]
|
|
80
|
+
legacy_tox_ini = """
|
|
81
|
+
[tox]
|
|
82
|
+
isolated_build = true
|
|
83
|
+
skipsdist = true
|
|
84
|
+
envlist =
|
|
85
|
+
style
|
|
86
|
+
py39
|
|
87
|
+
py310
|
|
88
|
+
py311
|
|
89
|
+
py312
|
|
90
|
+
doc
|
|
91
|
+
coverage
|
|
92
|
+
|
|
93
|
+
[testenv]
|
|
94
|
+
allowlist_externals = poetry
|
|
95
|
+
commands =
|
|
96
|
+
poetry install
|
|
97
|
+
poetry run pytest --showlocals --full-trace {posargs}
|
|
98
|
+
|
|
99
|
+
[testenv:style]
|
|
100
|
+
commands =
|
|
101
|
+
pip install pre-commit
|
|
102
|
+
pre-commit run --all-files
|
|
103
|
+
|
|
104
|
+
[testenv:doc]
|
|
105
|
+
commands =
|
|
106
|
+
poetry install --with doc --without dev
|
|
107
|
+
poetry run sphinx-build --builder html doc build/sphinx/html
|
|
108
|
+
poetry run sphinx-build --builder man doc build/sphinx/html
|
|
109
|
+
|
|
110
|
+
[testenv:coverage]
|
|
111
|
+
commands =
|
|
112
|
+
poetry install
|
|
113
|
+
poetry run pytest --cov --cov-fail-under=100 --cov-report term:skip-covered {posargs}
|
|
114
|
+
poetry run coverage html
|
|
115
|
+
"""
|
|
@@ -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
|
+
]
|
|
@@ -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()
|
|
@@ -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)
|