Skip to content

Commit 456b549

Browse files
committed
feat: add BaseSCIMClient 'resource_types' parameter
1 parent 0c43797 commit 456b549

10 files changed

Lines changed: 211 additions & 295 deletions

File tree

doc/changelog.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
Changelog
22
=========
33

4+
[0.4.0] - Unreleased
5+
--------------------
6+
7+
.. warning::
8+
9+
This version comes with breaking changes:
10+
11+
- :class:`~scim2_client.BaseSCIMClient` takes a mandatory :paramref:`~scim2_client.BaseSCIMClient.resource_types` parameter.
12+
413
[0.3.3] - 2024-11-29
514
--------------------
615

scim2_client/client.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ class BaseSCIMClient:
4747
4848
This class can be inherited and used as a basis for request engine integration.
4949
50-
:param resource_models: A tuple of :class:`~scim2_models.Resource` types expected to be handled by the SCIM client.
50+
:param resource_models: A collection of :class:`~scim2_models.Resource` models expected to be handled by the SCIM client.
5151
If a request payload describe a resource that is not in this list, an exception will be raised.
52+
:param resource_types: A collection of :class:`~scim2_models.ResourceType` that will be used to guess the
53+
server endpoints associated with the resources.
5254
:param check_request_payload: If :data:`False`,
5355
:code:`resource` is expected to be a dict that will be passed as-is in the request.
5456
This value can be overwritten in methods.
@@ -146,13 +148,15 @@ class BaseSCIMClient:
146148
def __init__(
147149
self,
148150
resource_models: Optional[Collection[type[Resource]]] = None,
151+
resource_types: Optional[Collection[ResourceType]] = None,
149152
check_request_payload: bool = True,
150153
check_response_payload: bool = True,
151154
raise_scim_errors: bool = True,
152155
):
153156
self.resource_models = tuple(
154157
set(resource_models or []) | {ResourceType, Schema, ServiceProviderConfig}
155158
)
159+
self.resource_types = resource_types
156160
self.check_request_payload = check_request_payload
157161
self.check_response_payload = check_response_payload
158162
self.raise_scim_errors = raise_scim_errors
@@ -169,16 +173,19 @@ def resource_endpoint(self, resource_model: Optional[type[Resource]]) -> str:
169173
if resource_model is None:
170174
return "/"
171175

176+
if resource_model in (ResourceType, Schema):
177+
return f"/{resource_model.__name__}s"
178+
172179
# This one takes no final 's'
173180
if resource_model is ServiceProviderConfig:
174181
return "/ServiceProviderConfig"
175182

176-
try:
177-
first_bracket_index = resource_model.__name__.index("[")
178-
root_name = resource_model.__name__[:first_bracket_index]
179-
except ValueError:
180-
root_name = resource_model.__name__
181-
return f"/{root_name}s"
183+
schema = resource_model.model_fields["schemas"].default[0]
184+
for resource_type in self.resource_types or []:
185+
if schema == resource_type.schema_:
186+
return resource_type.endpoint
187+
188+
raise SCIMRequestError(f"No ResourceType is matching the schema: {schema}")
182189

183190
def check_response(
184191
self,

tests/conftest.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import pytest
2+
from httpx import Client
3+
from scim2_models import Group
4+
from scim2_models import ResourceType
5+
from scim2_models import User
6+
7+
from scim2_client.engines.httpx import SyncSCIMClient
8+
9+
10+
@pytest.fixture
11+
def sync_client(httpserver):
12+
client = Client(base_url=f"http://localhost:{httpserver.port}")
13+
scim_client = SyncSCIMClient(
14+
client,
15+
resource_models=[User, Group],
16+
resource_types=[
17+
ResourceType.from_resource(User),
18+
ResourceType.from_resource(Group),
19+
],
20+
)
21+
return scim_client

tests/engines/test_httpx.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,7 @@ def server():
2323
backend = InMemoryBackend()
2424
provider = SCIMProvider(backend)
2525
provider.register_schema(User.to_schema())
26-
provider.register_resource_type(
27-
ResourceType(
28-
id="User",
29-
name="User",
30-
endpoint="/Users",
31-
schema="urn:ietf:params:scim:schemas:core:2.0:User",
32-
)
33-
)
34-
26+
provider.register_resource_type(ResourceType.from_resource(User))
3527
host = "localhost"
3628
port = portpicker.pick_unused_port()
3729
httpd = wsgiref.simple_server.make_server(host, port, provider)
@@ -48,7 +40,11 @@ def server():
4840
def test_sync_engine(server):
4941
host, port = server
5042
client = Client(base_url=f"http://{host}:{port}")
51-
scim_client = SyncSCIMClient(client, resource_models=(User,))
43+
scim_client = SyncSCIMClient(
44+
client,
45+
resource_models=[User],
46+
resource_types=[ResourceType.from_resource(User)],
47+
)
5248

5349
request_user = User(user_name="foo", display_name="bar")
5450
response_user = scim_client.create(request_user)
@@ -81,7 +77,11 @@ def test_sync_engine(server):
8177
async def test_async_engine(server):
8278
host, port = server
8379
client = AsyncClient(base_url=f"http://{host}:{port}")
84-
scim_client = AsyncSCIMClient(client, resource_models=(User,))
80+
scim_client = AsyncSCIMClient(
81+
client,
82+
resource_models=(User,),
83+
resource_types=[ResourceType.from_resource(User)],
84+
)
8585

8686
request_user = User(user_name="foo", display_name="bar")
8787
response_user = await scim_client.create(request_user)

tests/engines/test_werkzeug.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,17 @@
1818
def scim_provider():
1919
provider = SCIMProvider(InMemoryBackend())
2020
provider.register_schema(User.to_schema())
21-
provider.register_resource_type(
22-
ResourceType(
23-
id="User",
24-
name="User",
25-
endpoint="/Users",
26-
schema="urn:ietf:params:scim:schemas:core:2.0:User",
27-
)
28-
)
21+
provider.register_resource_type(ResourceType.from_resource(User))
2922
return provider
3023

3124

3225
@pytest.fixture
3326
def scim_client(scim_provider):
34-
return TestSCIMClient(app=scim_provider, resource_models=(User,))
27+
return TestSCIMClient(
28+
app=scim_provider,
29+
resource_models=(User,),
30+
resource_types=[ResourceType.from_resource(User)],
31+
)
3532

3633

3734
def test_werkzeug_engine(scim_client):

tests/test_create.py

Lines changed: 31 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,20 @@
11
import datetime
2+
from typing import Optional
23

34
import pytest
4-
from httpx import Client
55
from scim2_models import Error
6-
from scim2_models import Group
76
from scim2_models import Meta
7+
from scim2_models import Resource
88
from scim2_models import User
99

1010
from scim2_client import RequestNetworkError
1111
from scim2_client import RequestPayloadValidationError
1212
from scim2_client import SCIMClientError
1313
from scim2_client import SCIMRequestError
1414
from scim2_client import UnexpectedStatusCode
15-
from scim2_client.engines.httpx import SyncSCIMClient
1615

1716

18-
def test_create_user(httpserver):
17+
def test_create_user(httpserver, sync_client):
1918
"""Nominal case for a User creation object."""
2019
httpserver.expect_request("/Users", method="POST").respond_with_json(
2120
{
@@ -34,10 +33,7 @@ def test_create_user(httpserver):
3433
)
3534

3635
user_request = User(user_name="bjensen@example.com")
37-
38-
client = Client(base_url=f"http://localhost:{httpserver.port}")
39-
scim_client = SyncSCIMClient(client, resource_models=(User,))
40-
response = scim_client.create(user_request)
36+
response = sync_client.create(user_request)
4137

4238
user_created = User(
4339
id="2819c223-7f76-453a-919d-413861904646",
@@ -57,7 +53,7 @@ def test_create_user(httpserver):
5753
assert response == user_created
5854

5955

60-
def test_create_dict_user(httpserver):
56+
def test_create_dict_user(httpserver, sync_client):
6157
"""Nominal case for a User creation object, when passing a dict instead of a resource."""
6258
httpserver.expect_request("/Users", method="POST").respond_with_json(
6359
{
@@ -80,9 +76,7 @@ def test_create_dict_user(httpserver):
8076
"userName": "bjensen@example.com",
8177
}
8278

83-
client = Client(base_url=f"http://localhost:{httpserver.port}")
84-
scim_client = SyncSCIMClient(client, resource_models=(User,))
85-
response = scim_client.create(user_request)
79+
response = sync_client.create(user_request)
8680

8781
user_created = User(
8882
id="2819c223-7f76-453a-919d-413861904646",
@@ -102,7 +96,7 @@ def test_create_dict_user(httpserver):
10296
assert response == user_created
10397

10498

105-
def test_create_dict_user_bad_schema(httpserver):
99+
def test_create_dict_user_bad_schema(httpserver, sync_client):
106100
"""Test when passing a resource dict with an unknown or invalid schema."""
107101
httpserver.expect_request("/Users", method="POST").respond_with_json(
108102
{
@@ -125,29 +119,25 @@ def test_create_dict_user_bad_schema(httpserver):
125119
"userName": "bjensen@example.com",
126120
}
127121

128-
client = Client(base_url=f"http://localhost:{httpserver.port}")
129-
scim_client = SyncSCIMClient(client, resource_models=(User,))
130122
with pytest.raises(
131123
SCIMClientError, match="Cannot guess resource type from the payload"
132124
):
133-
scim_client.create(user_request)
125+
sync_client.create(user_request)
134126

135127

136-
def test_dont_check_response_payload(httpserver):
128+
def test_dont_check_response_payload(httpserver, sync_client):
137129
"""Test the check_response_payload_attribute."""
138130
httpserver.expect_request("/Users", method="POST").respond_with_json(
139131
{"foo": "bar"}, status=201
140132
)
141133

142134
user_request = User(user_name="bjensen@example.com")
143135

144-
client = Client(base_url=f"http://localhost:{httpserver.port}")
145-
scim_client = SyncSCIMClient(client, resource_models=(User,))
146-
response = scim_client.create(resource=user_request, check_response_payload=False)
136+
response = sync_client.create(resource=user_request, check_response_payload=False)
147137
assert response == {"foo": "bar"}
148138

149139

150-
def test_dont_check_request_payload(httpserver):
140+
def test_dont_check_request_payload(httpserver, sync_client):
151141
"""Test the check_request_payload_attribute.
152142
153143
TODO: Actually check that the payload is sent through the network
@@ -173,9 +163,7 @@ def test_dont_check_request_payload(httpserver):
173163
"userName": "bjensen@example.com",
174164
}
175165

176-
client = Client(base_url=f"http://localhost:{httpserver.port}")
177-
scim_client = SyncSCIMClient(client, resource_models=(User,))
178-
response = scim_client.create(
166+
response = sync_client.create(
179167
resource=user_request, check_request_payload=False, url="/Users"
180168
)
181169

@@ -194,7 +182,7 @@ def test_dont_check_request_payload(httpserver):
194182
assert response == user_created
195183

196184

197-
def test_conflict(httpserver):
185+
def test_conflict(httpserver, sync_client):
198186
"""Nominal case for a User creation object."""
199187
httpserver.expect_request("/Users", method="POST").respond_with_json(
200188
{
@@ -208,9 +196,7 @@ def test_conflict(httpserver):
208196

209197
user_request = User(user_name="bjensen@example.com")
210198

211-
client = Client(base_url=f"http://localhost:{httpserver.port}")
212-
scim_client = SyncSCIMClient(client, resource_models=(User,))
213-
response = scim_client.create(user_request, raise_scim_errors=False)
199+
response = sync_client.create(user_request, raise_scim_errors=False)
214200
assert response == Error(
215201
schemas=["urn:ietf:params:scim:api:messages:2.0:Error"],
216202
status=409,
@@ -219,7 +205,7 @@ def test_conflict(httpserver):
219205
)
220206

221207

222-
def test_no_200(httpserver):
208+
def test_no_200(httpserver, sync_client):
223209
"""User creation object should return 201 codes and no 200."""
224210
httpserver.expect_request("/Users", method="POST").respond_with_json(
225211
{
@@ -239,16 +225,14 @@ def test_no_200(httpserver):
239225

240226
user_request = User(user_name="bjensen@example.com")
241227

242-
client = Client(base_url=f"http://localhost:{httpserver.port}")
243-
scim_client = SyncSCIMClient(client, resource_models=(User,))
244228
with pytest.raises(UnexpectedStatusCode):
245-
scim_client.create(user_request)
246-
scim_client.create(user_request, expected_status_codes=None)
247-
scim_client.create(user_request, expected_status_codes=[200, 201])
229+
sync_client.create(user_request)
230+
sync_client.create(user_request, expected_status_codes=None)
231+
sync_client.create(user_request, expected_status_codes=[200, 201])
248232

249233

250234
@pytest.mark.parametrize("code", [400, 401, 403, 404, 500])
251-
def test_errors(httpserver, code):
235+
def test_errors(httpserver, code, sync_client):
252236
"""Test error cases defined in RFC7644."""
253237
httpserver.expect_request("/Users", method="POST").respond_with_json(
254238
{
@@ -261,9 +245,7 @@ def test_errors(httpserver, code):
261245

262246
user_request = User(user_name="bjensen@example.com")
263247

264-
client = Client(base_url=f"http://localhost:{httpserver.port}")
265-
scim_client = SyncSCIMClient(client, resource_models=(User,))
266-
response = scim_client.create(user_request, raise_scim_errors=False)
248+
response = sync_client.create(user_request, raise_scim_errors=False)
267249

268250
assert response == Error(
269251
schemas=["urn:ietf:params:scim:api:messages:2.0:Error"],
@@ -272,35 +254,34 @@ def test_errors(httpserver, code):
272254
)
273255

274256

275-
def test_invalid_resource_model(httpserver):
257+
def test_invalid_resource_model(sync_client):
276258
"""Test that resource_models passed to the method must be part of BaseSCIMClient.resource_models."""
277-
client = Client(base_url=f"http://localhost:{httpserver.port}")
278-
scim_client = SyncSCIMClient(client, resource_models=(User,))
259+
260+
class MyResource(Resource):
261+
schemas: list[str] = ["urn:ietf:params:scim:schemas:core:2.0:MyResource"]
262+
display_name: Optional[str] = None
263+
279264
with pytest.raises(SCIMRequestError, match=r"Unknown resource type"):
280-
scim_client.create(Group(display_name="foobar"))
265+
sync_client.create(MyResource(display_name="foobar"))
281266

282267

283-
def test_request_validation_error(httpserver):
268+
def test_request_validation_error(sync_client):
284269
"""Test that incorrect input raise a RequestPayloadValidationError."""
285-
client = Client(base_url=f"http://localhost:{httpserver.port}")
286-
scim_client = SyncSCIMClient(client, resource_models=(User,))
287270
with pytest.raises(
288271
RequestPayloadValidationError, match="Server request payload validation error"
289272
):
290-
scim_client.create(
273+
sync_client.create(
291274
{
292275
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
293276
"active": "not-a-bool",
294277
}
295278
)
296279

297280

298-
def test_request_network_error(httpserver):
281+
def test_request_network_error(sync_client):
299282
"""Test that httpx exceptions are transformed in RequestNetworkError."""
300-
client = Client(base_url=f"http://localhost:{httpserver.port}")
301-
scim_client = SyncSCIMClient(client, resource_models=(User,))
302283
user_request = User(user_name="bjensen@example.com")
303284
with pytest.raises(
304285
RequestNetworkError, match="Network error happened during request"
305286
):
306-
scim_client.create(user_request, url="http://invalid.test")
287+
sync_client.create(user_request, url="http://invalid.test")

0 commit comments

Comments
 (0)