Skip to content

Commit 7359012

Browse files
committed
refactor: describe the server with a ScimProvider built from the configuration files and the discovery
1 parent a6cb469 commit 7359012

5 files changed

Lines changed: 195 additions & 73 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@ Changelog
77
Changed
88
^^^^^^^
99
- Python 3.11 is now the minimum supported version.
10+
- scim2-client 0.9.0 and scim2-tester 0.4.0 are now the minimum supported versions.
1011

1112
Fixed
1213
^^^^^
13-
- Without ``--schemas``, the schemas and the resource types are discovered on the server
14-
again with scim2-client 0.9. Only ``User`` and ``Group`` were known, without their
15-
extensions.
14+
- What the configuration files leave out is discovered on the server again with
15+
scim2-client 0.9. Without ``--schemas``, only ``User`` and ``Group`` were known,
16+
without their extensions. With ``--schemas`` only, the resource types were guessed
17+
from the schemas.
18+
- ``--resource-types`` without ``--schemas`` no longer crashes.
19+
- A server description whose resource types name unknown schemas is reported as a
20+
readable error instead of a traceback.
1621

1722
Security
1823
^^^^^^^^

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ classifiers = [
2727
requires-python = ">= 3.11"
2828
dependencies = [
2929
"click>=8.1.7",
30-
"scim2-client[httpx2]>=0.8.0",
31-
"scim2-tester>=0.3.0",
30+
"scim2-client[httpx2]>=0.9.0",
31+
"scim2-tester>=0.4.0",
3232
"sphinx-click-rst-to-ansi-formatter>=0.1.0",
3333
"pydanclick>=0.5.0",
3434
"pygments>=2.18.0",

‎scim2_cli/__init__.py‎

Lines changed: 36 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
from httpx2 import Client
66
from scim2_client.engines.httpx2 import SyncSCIMClient
77
from scim2_models import ListResponse
8-
from scim2_models import Resource
98
from scim2_models import ResourceType
109
from scim2_models import Schema
10+
from scim2_models import ScimProvider
11+
from scim2_models import ScimProviderError
1112
from scim2_models import ServiceProviderConfig
1213
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
1314

@@ -25,46 +26,34 @@
2526
from scim2_cli.utils import split_headers
2627

2728

28-
def load_config_files(
29-
schemas_fd, resource_types_fd, service_provider_config_fd
30-
) -> tuple[
31-
list[type[Resource]] | None,
32-
list[ResourceType] | None,
33-
ServiceProviderConfig | None,
34-
]:
35-
if schemas_fd:
36-
schemas_payload = json.load(schemas_fd)
37-
if isinstance(schemas_payload, dict):
38-
schemas_obj = ListResponse[Schema].model_validate(schemas_payload).resources
39-
else:
40-
schemas_obj = [Schema.model_validate(schema) for schema in schemas_payload]
41-
resource_models = [Resource.from_schema(schema) for schema in schemas_obj]
42-
43-
else:
44-
resource_models = None
45-
46-
if resource_types_fd:
47-
resource_types_payload = json.load(resource_types_fd)
48-
if isinstance(schemas_payload, dict):
49-
resource_types = (
50-
ListResponse[ResourceType]
51-
.model_validate(resource_types_payload)
52-
.resources
53-
)
54-
else:
55-
resource_types = [
56-
ResourceType.model_validate(item) for item in resource_types_payload
57-
]
58-
else:
59-
resource_types = None
60-
61-
if service_provider_config_fd:
62-
spc_payload = json.load(service_provider_config_fd)
63-
service_provider_config = ServiceProviderConfig.model_validate(spc_payload)
64-
else:
65-
service_provider_config = None
66-
67-
return resource_models, resource_types, service_provider_config
29+
def load_objects(fd, model):
30+
"""Read a list of objects, bare or wrapped in a ListResponse, from a JSON file."""
31+
payload = json.load(fd)
32+
if isinstance(payload, dict):
33+
return ListResponse[model].model_validate(payload).resources or []
34+
return [model.model_validate(item) for item in payload]
35+
36+
37+
def describe_server(
38+
scim_client, schemas_fd, resource_types_fd, service_provider_config_fd
39+
) -> ScimProvider:
40+
"""Describe the server with the configuration files, and query it for the others."""
41+
resource_types = (
42+
load_objects(resource_types_fd, ResourceType)
43+
if resource_types_fd
44+
else scim_client.query(ResourceType).resources or []
45+
)
46+
schemas = (
47+
load_objects(schemas_fd, Schema)
48+
if schemas_fd
49+
else scim_client.query(Schema).resources or []
50+
)
51+
config = (
52+
ServiceProviderConfig.model_validate(json.load(service_provider_config_fd))
53+
if service_provider_config_fd
54+
else scim_client.query(ServiceProviderConfig)
55+
)
56+
return ScimProvider.from_discovery(schemas, resource_types, config)
6857

6958

7059
@click.group(cls=make_rst_to_ansi_formatter(DOC_URL, group=True))
@@ -123,31 +112,21 @@ def cli(
123112
headers_dict = split_headers(header)
124113
client = Client(base_url=url, headers=headers_dict, verify=not no_verify)
125114

126-
resource_models, resource_types_obj, spc_obj = load_config_files(
127-
schemas, resource_types, service_provider_config
128-
)
129-
130-
scim_client = SyncSCIMClient(
131-
client,
132-
resource_models=resource_models,
133-
resource_types=resource_types_obj,
134-
service_provider_config=spc_obj,
135-
)
115+
scim_client = SyncSCIMClient(client)
136116
try:
137-
scim_client.discover(
138-
schemas=not bool(schemas),
139-
resource_types=not bool(resource_types),
140-
service_provider_config=not bool(service_provider_config),
117+
scim_client.provider = describe_server(
118+
scim_client, schemas, resource_types, service_provider_config
141119
)
142-
except SCIM_EXCEPTIONS as exc:
120+
except (*SCIM_EXCEPTIONS, ScimProviderError) as exc:
143121
raise exception_to_click_error(exc) from exc
144122

123+
provider = scim_client.provider
145124
ctx.obj["client"] = scim_client
146125
ctx.obj["resource_models"] = {
147126
escape_control_characters(
148127
re.sub(r"\[.*\]", "", resource_model.__name__.lower())
149128
): resource_model
150-
for resource_model in ctx.obj["client"].resource_models
129+
for resource_model in map(provider.model_for, provider.resource_types)
151130
}
152131

153132
if not click.get_text_stream("stdin").isatty(): # pragma: no cover

‎tests/test_cli.py‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,134 @@ def test_custom_configuration_in_list_response(
275275
assert result.exit_code == 0
276276

277277

278+
@pytest.mark.parametrize("in_list_response", [False, True])
279+
def test_custom_resource_types_only(
280+
runner, httpserver, simple_user_payload, tmp_path, resource_types, in_list_response
281+
):
282+
"""Test passing only the resource types, the schemas being discovered."""
283+
payload = (
284+
ListResponse[ResourceType](
285+
total_results=len(resource_types),
286+
start_index=1,
287+
items_per_page=len(resource_types),
288+
resources=resource_types,
289+
).model_dump()
290+
if in_list_response
291+
else [resource_type.model_dump() for resource_type in resource_types]
292+
)
293+
resource_types_path = tmp_path / "resource_types.json"
294+
with open(resource_types_path, "w") as fd:
295+
json.dump(payload, fd)
296+
297+
httpserver.expect_request(
298+
"/somewhere-different/foobar",
299+
method="GET",
300+
).respond_with_json(
301+
simple_user_payload("foobar"),
302+
status=200,
303+
content_type="application/scim+json",
304+
)
305+
306+
result = runner.invoke(
307+
cli,
308+
[
309+
"--url",
310+
httpserver.url_for("/"),
311+
"--resource-types",
312+
resource_types_path,
313+
"query",
314+
"user",
315+
"foobar",
316+
],
317+
catch_exceptions=False,
318+
)
319+
assert result.exit_code == 0, result.output
320+
321+
322+
def test_custom_schemas_only(
323+
runner,
324+
httpserver,
325+
simple_user_payload,
326+
tmp_path,
327+
service_provider_configuration,
328+
schemas,
329+
resource_types,
330+
):
331+
"""Test passing only the schemas, the resource types being discovered."""
332+
schemas_path = tmp_path / "schemas.json"
333+
with open(schemas_path, "w") as fd:
334+
json.dump([schema.model_dump() for schema in schemas], fd)
335+
336+
httpserver.clear_all_handlers()
337+
httpserver.expect_request("/ResourceTypes").respond_with_json(
338+
ListResponse[ResourceType](
339+
total_results=len(resource_types), resources=resource_types
340+
).model_dump(),
341+
content_type="application/scim+json",
342+
)
343+
httpserver.expect_request("/ServiceProviderConfig").respond_with_json(
344+
service_provider_configuration.model_dump(),
345+
content_type="application/scim+json",
346+
)
347+
httpserver.expect_request(
348+
"/somewhere-different/foobar",
349+
method="GET",
350+
).respond_with_json(
351+
simple_user_payload("foobar"),
352+
status=200,
353+
content_type="application/scim+json",
354+
)
355+
356+
result = runner.invoke(
357+
cli,
358+
[
359+
"--url",
360+
httpserver.url_for("/"),
361+
"--schemas",
362+
schemas_path,
363+
"query",
364+
"user",
365+
"foobar",
366+
],
367+
catch_exceptions=False,
368+
)
369+
assert result.exit_code == 0, result.output
370+
371+
372+
def test_incoherent_server_description(runner, httpserver, tmp_path, schemas):
373+
"""Test that a resource type naming an unknown schema displays a readable error."""
374+
resource_types_path = tmp_path / "resource_types.json"
375+
with open(resource_types_path, "w") as fd:
376+
json.dump(
377+
[
378+
ResourceType(
379+
id="Group",
380+
name="Group",
381+
endpoint="/Groups",
382+
schema_="urn:ietf:params:scim:schemas:core:2.0:Group",
383+
).model_dump()
384+
],
385+
fd,
386+
)
387+
388+
result = runner.invoke(
389+
cli,
390+
[
391+
"--url",
392+
httpserver.url_for("/"),
393+
"--resource-types",
394+
resource_types_path,
395+
"query",
396+
],
397+
catch_exceptions=False,
398+
)
399+
assert result.exit_code == 1, result.output
400+
assert (
401+
"Error: No resource describes urn:ietf:params:scim:schemas:core:2.0:Group"
402+
in result.output
403+
)
404+
405+
278406
def test_custom_configuration_by_env(
279407
runner,
280408
httpserver,

‎uv.lock‎

Lines changed: 21 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)