Skip to content

Commit d4fbc81

Browse files
authored
Merge pull request #28 from python-scim/werkzeug-engine
werkzeug engine implementation
2 parents 6f256d5 + 335f17e commit d4fbc81

File tree

10 files changed

+389
-4
lines changed

10 files changed

+389
-4
lines changed

.github/workflows/tests.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ jobs:
3131
enable-cache: true
3232
- name: Install Python ${{ matrix.python }}
3333
run: uv python install ${{ matrix.python }}
34+
- name: Install dependencies
35+
run: uv sync --all-extras
3436
- name: Run tests
3537
run: uv run pytest --showlocals
3638

@@ -44,7 +46,7 @@ jobs:
4446
with:
4547
enable-cache: true
4648
- name: Install minimum dependencies
47-
run: uv sync --resolution=lowest-direct
49+
run: uv sync --resolution=lowest-direct --all-extras
4850
- name: Run tests
4951
run: uv run pytest --showlocals
5052

doc/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ Changelog
77
.. warning::
88

99
This version comes with breaking changes:
10+
1011
- `httpx` is no longer a direct dependency, it is shipped in the `httpx` packaging extra.
1112
- Use ``scim2_client.engines.httpx.SyncSCIMClient`` instead of ``scim2_client.SCIMClient``.
1213

1314
Added
1415
^^^^^
1516
- The `Unknown resource type` request error keeps a reference to the faulty payload.
17+
- New `werkzeug` request engine for application development purpose.
1618

1719
Changed
1820
^^^^^^^

doc/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
intersphinx_mapping = {
4343
"python": ("https://docs.python.org/3", None),
4444
"scim2_models": ("https://scim2-models.readthedocs.io/en/latest/", None),
45+
"werkzeug": ("https://werkzeug.palletsprojects.com", None),
4546
}
4647

4748
# -- Options for HTML output ----------------------------------------------

doc/reference.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,11 @@ Reference
44
.. automodule:: scim2_client
55
:members:
66
:member-order: bysource
7+
8+
.. automodule:: scim2_client.engines.httpx
9+
:members:
10+
:member-order: bysource
11+
12+
.. automodule:: scim2_client.engines.werkzeug
13+
:members:
14+
:member-order: bysource

doc/tutorial.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,19 @@ To achieve this, all the methods provide the following parameters, all are :data
8383
which value will excluded from the request payload, and which values are
8484
expected in the response payload.
8585

86+
Engines
87+
=======
88+
89+
scim2-client comes with a light abstraction layers that allows for different requests engines.
90+
Currently those engines are shipped:
91+
92+
- :class:`~scim2_client.engines.httpx.SyncSCIMClient`: A synchronous engine using `httpx <https://github.com/encode/httpx>`_ to perform the HTTP requests.
93+
- :class:`~scim2_client.engines.werkzeug.TestSCIMClient`: A test engine for development purposes.
94+
It takes a WSGI app and directly execute the server code instead of performing real HTTP requests.
95+
This is faster in unit test suites, and helpful to catch the server exceptions.
96+
97+
You can easily implement your own engine by inheriting from :class:`~scim2_client.BaseSCIMClient`.
98+
8699
Additional request parameters
87100
=============================
88101

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ httpx = [
3535
"httpx>=0.24.0",
3636
]
3737

38+
werkzeug = [
39+
"werkzeug>=3.1.3",
40+
]
41+
3842
[project.urls]
3943
documentation = "https://scim2-client.readthedocs.io"
4044
repository = "https://github.com/python-scim/scim2-client"
@@ -48,7 +52,9 @@ dev = [
4852
"pytest>=8.2.1",
4953
"pytest-coverage>=0.0",
5054
"pytest-httpserver>=1.0.10",
55+
"scim2-server >= 0.1.2; python_version>='3.10'",
5156
"tox-uv>=1.16.0",
57+
"werkzeug>=3.1.3",
5258
]
5359
doc = [
5460
"autodoc-pydantic>=2.2.0",

scim2_client/engines/werkzeug.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
from contextlib import contextmanager
2+
from typing import Optional
3+
from typing import Union
4+
from urllib.parse import urlencode
5+
6+
from scim2_models import AnyResource
7+
from scim2_models import Context
8+
from scim2_models import Error
9+
from scim2_models import ListResponse
10+
from scim2_models import Resource
11+
from scim2_models import SearchRequest
12+
from werkzeug.test import Client
13+
14+
from scim2_client.client import BaseSCIMClient
15+
from scim2_client.errors import SCIMClientError
16+
17+
18+
@contextmanager
19+
def handle_response_error(response):
20+
try:
21+
yield
22+
23+
except SCIMClientError as exc:
24+
exc.source = response
25+
raise exc
26+
27+
28+
class TestSCIMClient(BaseSCIMClient):
29+
"""A client based on :class:`Werkzeug test Client <werkzeug.test.Client>` for application development purposes.
30+
31+
This is helpful for developers of SCIM servers.
32+
This client avoids to perform real HTTP requests and directly execute the server code instead.
33+
This allows to dynamically catch the exceptions if something gets wrong.
34+
35+
:param client: A WSGI application instance that will be used to send requests.
36+
:param scim_prefix: The scim root endpoint in the application.
37+
:param resource_types: The client resource types.
38+
39+
.. code-block:: python
40+
41+
from scim2_client.engines.werkzeug import TestSCIMClient
42+
from scim2_models import User, Group
43+
44+
testclient = TestSCIMClient(app=scim_provider, resource_types=(User, Group))
45+
46+
request_user = User(user_name="foo", display_name="bar")
47+
response_user = scim_client.create(request_user)
48+
assert response_user.user_name == "foo"
49+
"""
50+
51+
def __init__(
52+
self,
53+
app,
54+
scim_prefix: str = "",
55+
resource_types: Optional[tuple[type[Resource]]] = None,
56+
):
57+
super().__init__(resource_types=resource_types)
58+
self.client = Client(app)
59+
self.scim_prefix = scim_prefix
60+
61+
def make_url(self, url: str) -> str:
62+
prefix = (
63+
self.scim_prefix[:-1]
64+
if self.scim_prefix.endswith("/")
65+
else self.scim_prefix
66+
)
67+
return f"{prefix}{url}"
68+
69+
def create(
70+
self,
71+
resource: Union[AnyResource, dict],
72+
check_request_payload: bool = True,
73+
check_response_payload: bool = True,
74+
expected_status_codes: Optional[
75+
list[int]
76+
] = BaseSCIMClient.CREATION_RESPONSE_STATUS_CODES,
77+
raise_scim_errors: bool = True,
78+
**kwargs,
79+
) -> Union[AnyResource, Error, dict]:
80+
url, payload, expected_types, request_kwargs = self.prepare_create_request(
81+
resource=resource,
82+
check_request_payload=check_request_payload,
83+
check_response_payload=check_response_payload,
84+
expected_status_codes=expected_status_codes,
85+
raise_scim_errors=raise_scim_errors,
86+
**kwargs,
87+
)
88+
89+
response = self.client.post(self.make_url(url), json=payload, **request_kwargs)
90+
91+
with handle_response_error(payload):
92+
return self.check_response(
93+
payload=response.json if response.text else None,
94+
status_code=response.status_code,
95+
headers=response.headers,
96+
expected_status_codes=expected_status_codes,
97+
expected_types=expected_types,
98+
check_response_payload=check_response_payload,
99+
raise_scim_errors=raise_scim_errors,
100+
scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
101+
)
102+
103+
def query(
104+
self,
105+
resource_type: Optional[type[Resource]] = None,
106+
id: Optional[str] = None,
107+
search_request: Optional[Union[SearchRequest, dict]] = None,
108+
check_request_payload: bool = True,
109+
check_response_payload: bool = True,
110+
expected_status_codes: Optional[
111+
list[int]
112+
] = BaseSCIMClient.QUERY_RESPONSE_STATUS_CODES,
113+
raise_scim_errors: bool = True,
114+
**kwargs,
115+
):
116+
url, payload, expected_types, request_kwargs = self.prepare_query_request(
117+
resource_type=resource_type,
118+
id=id,
119+
search_request=search_request,
120+
check_request_payload=check_request_payload,
121+
check_response_payload=check_response_payload,
122+
expected_status_codes=expected_status_codes,
123+
raise_scim_errors=raise_scim_errors,
124+
**kwargs,
125+
)
126+
127+
query_string = urlencode(payload, doseq=False) if payload else None
128+
response = self.client.get(
129+
self.make_url(url), query_string=query_string, **request_kwargs
130+
)
131+
132+
with handle_response_error(payload):
133+
return self.check_response(
134+
payload=response.json if response.text else None,
135+
status_code=response.status_code,
136+
headers=response.headers,
137+
expected_status_codes=expected_status_codes,
138+
expected_types=expected_types,
139+
check_response_payload=check_response_payload,
140+
raise_scim_errors=raise_scim_errors,
141+
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
142+
)
143+
144+
def search(
145+
self,
146+
search_request: Optional[SearchRequest] = None,
147+
check_request_payload: bool = True,
148+
check_response_payload: bool = True,
149+
expected_status_codes: Optional[
150+
list[int]
151+
] = BaseSCIMClient.SEARCH_RESPONSE_STATUS_CODES,
152+
raise_scim_errors: bool = True,
153+
**kwargs,
154+
) -> Union[AnyResource, ListResponse[AnyResource], Error, dict]:
155+
url, payload, expected_types, request_kwargs = self.prepare_search_request(
156+
search_request=search_request,
157+
check_request_payload=check_request_payload,
158+
check_response_payload=check_response_payload,
159+
expected_status_codes=expected_status_codes,
160+
raise_scim_errors=raise_scim_errors,
161+
**kwargs,
162+
)
163+
164+
response = self.client.post(self.make_url(url), json=payload, **request_kwargs)
165+
166+
with handle_response_error(response):
167+
return self.check_response(
168+
payload=response.json if response.text else None,
169+
status_code=response.status_code,
170+
headers=response.headers,
171+
expected_status_codes=expected_status_codes,
172+
expected_types=expected_types,
173+
check_response_payload=check_response_payload,
174+
raise_scim_errors=raise_scim_errors,
175+
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
176+
)
177+
178+
def delete(
179+
self,
180+
resource_type: type,
181+
id: str,
182+
check_response_payload: bool = True,
183+
expected_status_codes: Optional[
184+
list[int]
185+
] = BaseSCIMClient.DELETION_RESPONSE_STATUS_CODES,
186+
raise_scim_errors: bool = True,
187+
**kwargs,
188+
) -> Optional[Union[Error, dict]]:
189+
url, request_kwargs = self.prepare_delete_request(
190+
resource_type=resource_type,
191+
id=id,
192+
check_response_payload=check_response_payload,
193+
expected_status_codes=expected_status_codes,
194+
raise_scim_errors=raise_scim_errors,
195+
**kwargs,
196+
)
197+
198+
response = self.client.delete(self.make_url(url), **request_kwargs)
199+
200+
with handle_response_error(response):
201+
return self.check_response(
202+
payload=response.json if response.text else None,
203+
status_code=response.status_code,
204+
headers=response.headers,
205+
expected_status_codes=expected_status_codes,
206+
check_response_payload=check_response_payload,
207+
raise_scim_errors=raise_scim_errors,
208+
)
209+
210+
def replace(
211+
self,
212+
resource: Union[AnyResource, dict],
213+
check_request_payload: bool = True,
214+
check_response_payload: bool = True,
215+
expected_status_codes: Optional[
216+
list[int]
217+
] = BaseSCIMClient.REPLACEMENT_RESPONSE_STATUS_CODES,
218+
raise_scim_errors: bool = True,
219+
**kwargs,
220+
) -> Union[AnyResource, Error, dict]:
221+
url, payload, expected_types, request_kwargs = self.prepare_replace_request(
222+
resource=resource,
223+
check_request_payload=check_request_payload,
224+
check_response_payload=check_response_payload,
225+
expected_status_codes=expected_status_codes,
226+
raise_scim_errors=raise_scim_errors,
227+
**kwargs,
228+
)
229+
230+
response = self.client.put(self.make_url(url), json=payload, **request_kwargs)
231+
232+
with handle_response_error(response):
233+
return self.check_response(
234+
payload=response.json if response.text else None,
235+
status_code=response.status_code,
236+
headers=response.headers,
237+
expected_status_codes=expected_status_codes,
238+
expected_types=expected_types,
239+
check_response_payload=check_response_payload,
240+
raise_scim_errors=raise_scim_errors,
241+
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
242+
)

tests/engines/__init__.py

Whitespace-only changes.

tests/engines/test_werkzeug.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import pytest
2+
from scim2_models import ResourceType
3+
from scim2_models import SearchRequest
4+
from scim2_models import User
5+
6+
from scim2_client.engines.werkzeug import TestSCIMClient
7+
from scim2_client.errors import SCIMResponseErrorObject
8+
9+
scim2_server = pytest.importorskip("scim2_server")
10+
from scim2_server.backend import InMemoryBackend # noqa: E402
11+
from scim2_server.provider import SCIMProvider # noqa: E402
12+
13+
14+
@pytest.fixture
15+
def scim_provider():
16+
provider = SCIMProvider(InMemoryBackend())
17+
provider.register_schema(User.to_schema())
18+
provider.register_resource_type(
19+
ResourceType(
20+
id="User",
21+
name="User",
22+
endpoint="/Users",
23+
schema="urn:ietf:params:scim:schemas:core:2.0:User",
24+
)
25+
)
26+
return provider
27+
28+
29+
@pytest.fixture
30+
def scim_client(scim_provider):
31+
return TestSCIMClient(app=scim_provider, resource_types=(User,))
32+
33+
34+
def test_werkzeug_engine(scim_client):
35+
request_user = User(user_name="foo", display_name="bar")
36+
response_user = scim_client.create(request_user)
37+
assert response_user.user_name == "foo"
38+
assert response_user.display_name == "bar"
39+
40+
response_user = scim_client.query(User, response_user.id)
41+
assert response_user.user_name == "foo"
42+
assert response_user.display_name == "bar"
43+
44+
req = SearchRequest()
45+
response_users = scim_client.search(req)
46+
assert response_users.resources[0].user_name == "foo"
47+
assert response_users.resources[0].display_name == "bar"
48+
49+
request_user = User(id=response_user.id, user_name="foo", display_name="baz")
50+
response_user = scim_client.replace(request_user)
51+
assert response_user.user_name == "foo"
52+
assert response_user.display_name == "baz"
53+
54+
response_user = scim_client.query(User, response_user.id)
55+
assert response_user.user_name == "foo"
56+
assert response_user.display_name == "baz"
57+
58+
scim_client.delete(User, response_user.id)
59+
with pytest.raises(SCIMResponseErrorObject):
60+
scim_client.query(User, response_user.id)

0 commit comments

Comments
 (0)