scim2-server 0.3.0__tar.gz → 0.3.1__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.
- {scim2_server-0.3.0 → scim2_server-0.3.1}/PKG-INFO +3 -3
- {scim2_server-0.3.0 → scim2_server-0.3.1}/README.md +1 -1
- {scim2_server-0.3.0 → scim2_server-0.3.1}/pyproject.toml +2 -2
- {scim2_server-0.3.0 → scim2_server-0.3.1}/pyproject.toml.orig +2 -2
- {scim2_server-0.3.0 → scim2_server-0.3.1}/scim2_server/backend.py +23 -58
- {scim2_server-0.3.0 → scim2_server-0.3.1}/scim2_server/bulk.py +65 -48
- {scim2_server-0.3.0 → scim2_server-0.3.1}/scim2_server/provider.py +31 -57
- {scim2_server-0.3.0 → scim2_server-0.3.1}/LICENSE +0 -0
- {scim2_server-0.3.0 → scim2_server-0.3.1}/scim2_server/__init__.py +0 -0
- {scim2_server-0.3.0 → scim2_server-0.3.1}/scim2_server/cli.py +0 -0
- {scim2_server-0.3.0 → scim2_server-0.3.1}/scim2_server/resources/default-resource-types.json +0 -0
- {scim2_server-0.3.0 → scim2_server-0.3.1}/scim2_server/resources/default-schemas.json +0 -0
- {scim2_server-0.3.0 → scim2_server-0.3.1}/scim2_server/resources/default-service-provider-config.json +0 -0
- {scim2_server-0.3.0 → scim2_server-0.3.1}/scim2_server/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: scim2-server
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Lightweight SCIM2 server prototype
|
|
5
5
|
Keywords: scim,scim2,provisioning,rfc7643,rfc7644
|
|
6
6
|
Author: Yaal Coop, Christian Friedrich Coors
|
|
@@ -217,7 +217,7 @@ Classifier: License :: OSI Approved :: Apache Software License
|
|
|
217
217
|
Classifier: Environment :: Web Environment
|
|
218
218
|
Classifier: Programming Language :: Python
|
|
219
219
|
Classifier: Operating System :: OS Independent
|
|
220
|
-
Requires-Dist: scim2-models>=0.
|
|
220
|
+
Requires-Dist: scim2-models>=0.10.0
|
|
221
221
|
Requires-Dist: werkzeug>=3.0.3
|
|
222
222
|
Requires-Python: >=3.11
|
|
223
223
|
Project-URL: repository, https://github.com/python-scim/scim2-server
|
|
@@ -239,7 +239,7 @@ they are lost once the process exits.
|
|
|
239
239
|
- [x] Unique Constraints
|
|
240
240
|
- [x] HTTP PATCH (Add/Remove/Replace)
|
|
241
241
|
- [x] Sorting
|
|
242
|
-
- [x] Bulk operations
|
|
242
|
+
- [x] Bulk operations
|
|
243
243
|
|
|
244
244
|
## Usage
|
|
245
245
|
|
|
@@ -13,7 +13,7 @@ they are lost once the process exits.
|
|
|
13
13
|
- [x] Unique Constraints
|
|
14
14
|
- [x] HTTP PATCH (Add/Remove/Replace)
|
|
15
15
|
- [x] Sorting
|
|
16
|
-
- [x] Bulk operations
|
|
16
|
+
- [x] Bulk operations
|
|
17
17
|
|
|
18
18
|
## Usage
|
|
19
19
|
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scim2-server"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Lightweight SCIM2 server prototype"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = [
|
|
@@ -29,7 +29,7 @@ classifiers = [
|
|
|
29
29
|
]
|
|
30
30
|
requires-python = ">= 3.11"
|
|
31
31
|
dependencies = [
|
|
32
|
-
"scim2-models>=0.
|
|
32
|
+
"scim2-models>=0.10.0",
|
|
33
33
|
"werkzeug>=3.0.3",
|
|
34
34
|
]
|
|
35
35
|
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scim2-server"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Lightweight SCIM2 server prototype"
|
|
9
9
|
authors = [
|
|
10
10
|
{name="Yaal Coop", email="contact@yaal.coop"},
|
|
@@ -29,7 +29,7 @@ classifiers = [
|
|
|
29
29
|
|
|
30
30
|
requires-python = ">= 3.11"
|
|
31
31
|
dependencies = [
|
|
32
|
-
"scim2-models>=0.
|
|
32
|
+
"scim2-models>=0.10.0",
|
|
33
33
|
"werkzeug>=3.0.3",
|
|
34
34
|
]
|
|
35
35
|
|
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
import dataclasses
|
|
2
1
|
import datetime
|
|
3
2
|
import pickle
|
|
4
3
|
import uuid
|
|
5
|
-
from inspect import isclass
|
|
6
4
|
from threading import Lock
|
|
7
5
|
from typing import Any
|
|
8
6
|
from typing import Union
|
|
7
|
+
from typing import cast
|
|
9
8
|
|
|
10
|
-
from scim2_models import
|
|
11
|
-
from scim2_models import CaseExact
|
|
12
|
-
from scim2_models import Extension
|
|
9
|
+
from scim2_models import AttributeBinding
|
|
13
10
|
from scim2_models import Meta
|
|
11
|
+
from scim2_models import Path
|
|
14
12
|
from scim2_models import Resource
|
|
15
13
|
from scim2_models import ResourceType
|
|
16
14
|
from scim2_models import ScimFilter
|
|
@@ -118,54 +116,6 @@ class InMemoryBackend(Backend):
|
|
|
118
116
|
implementation simple.
|
|
119
117
|
"""
|
|
120
118
|
|
|
121
|
-
@dataclasses.dataclass(frozen=True)
|
|
122
|
-
class UniquenessDescriptor:
|
|
123
|
-
"""Used to mimic uniqueness constraints e.g. from a SQL database."""
|
|
124
|
-
|
|
125
|
-
extension: str | None
|
|
126
|
-
field_name: str
|
|
127
|
-
case_exact: bool
|
|
128
|
-
schema: str
|
|
129
|
-
|
|
130
|
-
def is_declared_by(self, resource: Resource) -> bool:
|
|
131
|
-
"""Tell whether the model of a resource holds the schema of the attribute."""
|
|
132
|
-
model = type(resource)
|
|
133
|
-
return self.schema == model.__schema__ or (
|
|
134
|
-
self.schema in model.get_extension_models()
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
def get_attribute(self, resource: Resource) -> Any:
|
|
138
|
-
holder = getattr(resource, self.extension) if self.extension else resource
|
|
139
|
-
value = getattr(holder, self.field_name, None) if holder else None
|
|
140
|
-
if isinstance(value, str) and not self.case_exact:
|
|
141
|
-
return value.casefold()
|
|
142
|
-
return value
|
|
143
|
-
|
|
144
|
-
@classmethod
|
|
145
|
-
def collect_unique_attrs(
|
|
146
|
-
cls, model: type[BaseModel], extension: str | None = None
|
|
147
|
-
) -> list[UniquenessDescriptor]:
|
|
148
|
-
"""Return the uniqueness constraints the annotations of a model declare.
|
|
149
|
-
|
|
150
|
-
The ``id`` is left out: the backend issues it, so it cannot clash.
|
|
151
|
-
"""
|
|
152
|
-
descriptors = [
|
|
153
|
-
cls.UniquenessDescriptor(
|
|
154
|
-
extension,
|
|
155
|
-
field_name,
|
|
156
|
-
model.get_field_annotation(field_name, CaseExact) == CaseExact.true,
|
|
157
|
-
str(model.__schema__),
|
|
158
|
-
)
|
|
159
|
-
for field_name in model.model_fields
|
|
160
|
-
if field_name != "id"
|
|
161
|
-
and model.get_field_annotation(field_name, Uniqueness) != Uniqueness.none
|
|
162
|
-
]
|
|
163
|
-
for field_name in model.model_fields:
|
|
164
|
-
root_type = model.get_field_root_type(field_name)
|
|
165
|
-
if isclass(root_type) and issubclass(root_type, Extension):
|
|
166
|
-
descriptors.extend(cls.collect_unique_attrs(root_type, field_name))
|
|
167
|
-
return descriptors
|
|
168
|
-
|
|
169
119
|
def __init__(self):
|
|
170
120
|
super().__init__()
|
|
171
121
|
self.resources: list[Resource] = []
|
|
@@ -278,18 +228,33 @@ class InMemoryBackend(Backend):
|
|
|
278
228
|
schema that declares the attribute, whatever their resource type. A
|
|
279
229
|
missing value never clashes, as a SQL NULL does not.
|
|
280
230
|
"""
|
|
281
|
-
|
|
282
|
-
|
|
231
|
+
unique_paths = Path[type(resource)].iter_paths(
|
|
232
|
+
include_subattributes=False,
|
|
233
|
+
uniqueness=[Uniqueness.server, Uniqueness.global_],
|
|
234
|
+
)
|
|
235
|
+
for path in unique_paths:
|
|
236
|
+
attribute = cast(AttributeBinding, path.resolve())
|
|
237
|
+
value = self._unique_value(resource, attribute)
|
|
283
238
|
if value is None:
|
|
284
239
|
continue
|
|
285
240
|
for existing_resource in self.resources:
|
|
286
241
|
if (
|
|
287
|
-
|
|
288
|
-
and existing_resource
|
|
289
|
-
and unique_attribute.get_attribute(existing_resource) == value
|
|
242
|
+
existing_resource.id != resource.id
|
|
243
|
+
and self._unique_value(existing_resource, attribute) == value
|
|
290
244
|
):
|
|
291
245
|
raise UniquenessException()
|
|
292
246
|
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _unique_value(resource: Resource, attribute: AttributeBinding) -> Any:
|
|
249
|
+
"""Return the value a resource holds for a unique attribute, in the form it is compared in.
|
|
250
|
+
|
|
251
|
+
A resource whose schemas do not declare the attribute holds no value.
|
|
252
|
+
"""
|
|
253
|
+
value = Path[type(resource)](attribute.urn).get(resource, strict=False)
|
|
254
|
+
if isinstance(value, str) and not attribute.case_exact:
|
|
255
|
+
return value.casefold()
|
|
256
|
+
return value
|
|
257
|
+
|
|
293
258
|
@staticmethod
|
|
294
259
|
def _touch_resource(resource: Resource, last_modified: datetime.datetime):
|
|
295
260
|
"""Touches a resource (updates last_modified and version).
|
|
@@ -1,55 +1,71 @@
|
|
|
1
1
|
from collections.abc import Callable
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from scim2_models import BulkOperation
|
|
4
6
|
from scim2_models import InvalidValueException
|
|
5
7
|
from scim2_models import Resource
|
|
6
8
|
from werkzeug.exceptions import Conflict
|
|
7
9
|
|
|
8
10
|
BULK_ID_PREFIX = "bulkId:"
|
|
9
11
|
|
|
10
|
-
Resolver = Callable[[
|
|
11
|
-
"""Replaces the bulkId references of
|
|
12
|
+
Resolver = Callable[[BulkOperation], BulkOperation]
|
|
13
|
+
"""Replaces the bulkId references of an operation."""
|
|
12
14
|
|
|
13
|
-
OperationRunner = Callable[
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def raw_attribute(payload: Any, name: str) -> Any:
|
|
18
|
-
"""Return an attribute of a raw payload, whose names are case insensitive."""
|
|
19
|
-
if not isinstance(payload, dict):
|
|
20
|
-
return None
|
|
21
|
-
return next(
|
|
22
|
-
(value for key, value in payload.items() if key.casefold() == name.casefold()),
|
|
23
|
-
None,
|
|
24
|
-
)
|
|
15
|
+
OperationRunner = Callable[
|
|
16
|
+
[BulkOperation, Resolver], tuple[dict[str, Any], Resource | None]
|
|
17
|
+
]
|
|
18
|
+
"""Applies an operation once resolved, and returns its outcome and the resource it acted on."""
|
|
25
19
|
|
|
26
20
|
|
|
27
21
|
def replace_bulk_ids(value: Any, replace: Callable[[str], str]) -> Any:
|
|
28
|
-
"""Replace every "bulkId:" reference of a
|
|
22
|
+
"""Replace every "bulkId:" reference of a value.
|
|
23
|
+
|
|
24
|
+
A value without reference is returned as it is. Models are copied with
|
|
25
|
+
only the changed fields, so the fields the client set stay the same.
|
|
26
|
+
"""
|
|
29
27
|
if isinstance(value, str) and value.startswith(BULK_ID_PREFIX):
|
|
30
28
|
return replace(value.removeprefix(BULK_ID_PREFIX))
|
|
29
|
+
|
|
31
30
|
if isinstance(value, list):
|
|
32
|
-
|
|
31
|
+
items = [replace_bulk_ids(item, replace) for item in value]
|
|
32
|
+
changed = any(new is not old for new, old in zip(items, value, strict=True))
|
|
33
|
+
return items if changed else value
|
|
34
|
+
|
|
33
35
|
if isinstance(value, dict):
|
|
34
|
-
|
|
36
|
+
entries = {key: replace_bulk_ids(item, replace) for key, item in value.items()}
|
|
37
|
+
changed = any(entries[key] is not item for key, item in value.items())
|
|
38
|
+
return entries if changed else value
|
|
39
|
+
|
|
40
|
+
if isinstance(value, BaseModel):
|
|
41
|
+
updates = {}
|
|
42
|
+
for name in type(value).model_fields:
|
|
43
|
+
field = getattr(value, name)
|
|
44
|
+
replaced = replace_bulk_ids(field, replace)
|
|
45
|
+
if replaced is not field:
|
|
46
|
+
updates[name] = replaced
|
|
47
|
+
return value.model_copy(update=updates) if updates else value
|
|
48
|
+
|
|
35
49
|
return value
|
|
36
50
|
|
|
37
51
|
|
|
38
|
-
def resolve_operation(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
def resolve_operation(
|
|
53
|
+
operation: BulkOperation, replace: Callable[[str], str]
|
|
54
|
+
) -> BulkOperation:
|
|
55
|
+
"""Replace the "bulkId:" references of the path and the data of a bulk operation."""
|
|
56
|
+
updates: dict[str, Any] = {}
|
|
57
|
+
if operation.path is not None:
|
|
58
|
+
path = "/".join(
|
|
59
|
+
replace_bulk_ids(segment, replace) for segment in operation.path.split("/")
|
|
60
|
+
)
|
|
61
|
+
if path != operation.path:
|
|
62
|
+
updates["path"] = path
|
|
42
63
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
49
|
-
elif key.casefold() == "data":
|
|
50
|
-
value = replace_bulk_ids(value, replace)
|
|
51
|
-
resolved[key] = value
|
|
52
|
-
return resolved
|
|
64
|
+
data = replace_bulk_ids(operation.data, replace)
|
|
65
|
+
if data is not operation.data:
|
|
66
|
+
updates["data"] = data
|
|
67
|
+
|
|
68
|
+
return operation.model_copy(update=updates) if updates else operation
|
|
53
69
|
|
|
54
70
|
|
|
55
71
|
class BulkJob:
|
|
@@ -63,7 +79,7 @@ class BulkJob:
|
|
|
63
79
|
|
|
64
80
|
def __init__(
|
|
65
81
|
self,
|
|
66
|
-
operations: list[
|
|
82
|
+
operations: list[BulkOperation],
|
|
67
83
|
fail_on_errors: int | None,
|
|
68
84
|
run: OperationRunner,
|
|
69
85
|
):
|
|
@@ -76,10 +92,12 @@ class BulkJob:
|
|
|
76
92
|
self.errors = 0
|
|
77
93
|
|
|
78
94
|
self.creations: dict[str, int] = {}
|
|
79
|
-
for index,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
for index, operation in enumerate(operations):
|
|
96
|
+
if (
|
|
97
|
+
operation.method == BulkOperation.Method.post
|
|
98
|
+
and operation.bulk_id is not None
|
|
99
|
+
):
|
|
100
|
+
self.creations.setdefault(operation.bulk_id, index)
|
|
83
101
|
|
|
84
102
|
@property
|
|
85
103
|
def stopped(self) -> bool:
|
|
@@ -105,15 +123,15 @@ class BulkJob:
|
|
|
105
123
|
if index in self.results or index in self.running or self.stopped:
|
|
106
124
|
return
|
|
107
125
|
|
|
108
|
-
|
|
126
|
+
operation = self.operations[index]
|
|
109
127
|
self.running.add(index)
|
|
110
|
-
for bulk_id in self.references(
|
|
128
|
+
for bulk_id in self.references(operation):
|
|
111
129
|
if bulk_id in self.creations:
|
|
112
130
|
self.run_operation(self.creations[bulk_id])
|
|
113
131
|
|
|
114
132
|
if not self.stopped:
|
|
115
133
|
result, resource = self.run_resolved(
|
|
116
|
-
|
|
134
|
+
operation, lambda operation: self.resolve(index, operation)
|
|
117
135
|
)
|
|
118
136
|
self.results[index] = result
|
|
119
137
|
if result["status"] >= 400:
|
|
@@ -127,7 +145,7 @@ class BulkJob:
|
|
|
127
145
|
return bulk_id is not None and self.creations.get(bulk_id) == index
|
|
128
146
|
|
|
129
147
|
@staticmethod
|
|
130
|
-
def references(
|
|
148
|
+
def references(operation: BulkOperation) -> list[str]:
|
|
131
149
|
"""Return the bulkIds an operation references."""
|
|
132
150
|
bulk_ids: list[str] = []
|
|
133
151
|
|
|
@@ -135,23 +153,22 @@ class BulkJob:
|
|
|
135
153
|
bulk_ids.append(bulk_id)
|
|
136
154
|
return bulk_id
|
|
137
155
|
|
|
138
|
-
resolve_operation(
|
|
156
|
+
resolve_operation(operation, collect)
|
|
139
157
|
return bulk_ids
|
|
140
158
|
|
|
141
|
-
def resolve(self, index: int,
|
|
159
|
+
def resolve(self, index: int, operation: BulkOperation) -> BulkOperation:
|
|
142
160
|
"""Replace the bulkId references of an operation with the identifiers of the created resources.
|
|
143
161
|
|
|
144
162
|
:raises Conflict: When a referenced resource was not created, as
|
|
145
163
|
RFC 7644 §3.7.1 allows for circular references.
|
|
146
164
|
"""
|
|
147
|
-
bulk_id = raw_attribute(payload, "bulkId")
|
|
148
165
|
if (
|
|
149
|
-
|
|
150
|
-
and
|
|
151
|
-
and not self.is_creation(index, bulk_id)
|
|
166
|
+
operation.method == BulkOperation.Method.post
|
|
167
|
+
and operation.bulk_id is not None
|
|
168
|
+
and not self.is_creation(index, operation.bulk_id)
|
|
152
169
|
):
|
|
153
170
|
raise InvalidValueException(
|
|
154
|
-
detail=f"The bulkId {bulk_id} is not unique in the request"
|
|
171
|
+
detail=f"The bulkId {operation.bulk_id} is not unique in the request"
|
|
155
172
|
)
|
|
156
173
|
|
|
157
174
|
def replace(bulk_id: str) -> str:
|
|
@@ -161,4 +178,4 @@ class BulkJob:
|
|
|
161
178
|
raise Conflict(f"The bulkId {bulk_id} is part of a circular reference")
|
|
162
179
|
raise Conflict(f"No resource was created with the bulkId {bulk_id}")
|
|
163
180
|
|
|
164
|
-
return resolve_operation(
|
|
181
|
+
return resolve_operation(operation, replace)
|
|
@@ -47,7 +47,6 @@ from werkzeug.routing.exceptions import RequestRedirect
|
|
|
47
47
|
from scim2_server.backend import Backend
|
|
48
48
|
from scim2_server.bulk import BulkJob
|
|
49
49
|
from scim2_server.bulk import Resolver
|
|
50
|
-
from scim2_server.bulk import raw_attribute
|
|
51
50
|
from scim2_server.utils import load_default_service_provider_config
|
|
52
51
|
|
|
53
52
|
SEARCH_REQUEST_PARAMETERS = (
|
|
@@ -251,7 +250,9 @@ class SCIMApplication:
|
|
|
251
250
|
replacement = self.get_model(resource_type).model_validate(
|
|
252
251
|
payload, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST
|
|
253
252
|
)
|
|
254
|
-
|
|
253
|
+
# A PUT that changes nothing keeps meta.lastModified and the ETag.
|
|
254
|
+
if not replacement.replace(resource):
|
|
255
|
+
return resource
|
|
255
256
|
return self.backend.update_resource(resource_type, replacement)
|
|
256
257
|
|
|
257
258
|
def patch(
|
|
@@ -514,7 +515,10 @@ class SCIMApplication:
|
|
|
514
515
|
f"The payload exceeds the maxPayloadSize ({bulk.max_payload_size} bytes)"
|
|
515
516
|
)
|
|
516
517
|
|
|
517
|
-
bulk_request
|
|
518
|
+
bulk_request = BulkRequest[Union[tuple(self.get_models())]].model_validate( # noqa: UP007
|
|
519
|
+
request.json, scim_ctx=Context.BULK_REQUEST
|
|
520
|
+
)
|
|
521
|
+
operations = cast(list[BulkOperation], bulk_request.operations)
|
|
518
522
|
if bulk.max_operations is not None and len(operations) > bulk.max_operations:
|
|
519
523
|
raise RequestEntityTooLarge(
|
|
520
524
|
f"The number of operations exceeds the maxOperations ({bulk.max_operations})"
|
|
@@ -523,7 +527,9 @@ class SCIMApplication:
|
|
|
523
527
|
results = BulkJob(
|
|
524
528
|
operations,
|
|
525
529
|
bulk_request.fail_on_errors,
|
|
526
|
-
lambda
|
|
530
|
+
lambda operation, resolve: self.run_bulk_operation(
|
|
531
|
+
request, operation, resolve
|
|
532
|
+
),
|
|
527
533
|
).run()
|
|
528
534
|
return self.make_response(
|
|
529
535
|
BulkResponse[Union[tuple(self.get_models())]]( # noqa: UP007
|
|
@@ -531,51 +537,38 @@ class SCIMApplication:
|
|
|
531
537
|
).model_dump(scim_ctx=Context.BULK_RESPONSE)
|
|
532
538
|
)
|
|
533
539
|
|
|
534
|
-
def read_bulk_request(self, payload: Any) -> tuple[BulkRequest, list[Any]]:
|
|
535
|
-
"""Validate the envelope of a bulk request, and return it with its raw operations.
|
|
536
|
-
|
|
537
|
-
Each operation is validated on its own, so an invalid operation only
|
|
538
|
-
fails itself (RFC 7644 §3.7.3).
|
|
539
|
-
"""
|
|
540
|
-
key = (
|
|
541
|
-
next((key for key in payload if key.casefold() == "operations"), None)
|
|
542
|
-
if isinstance(payload, dict)
|
|
543
|
-
else None
|
|
544
|
-
)
|
|
545
|
-
operations = payload[key] if key else None
|
|
546
|
-
envelope = {**payload, key: []} if isinstance(operations, list) else payload
|
|
547
|
-
bulk_request = BulkRequest[Union[tuple(self.get_models())]].model_validate( # noqa: UP007
|
|
548
|
-
envelope, scim_ctx=Context.BULK_REQUEST
|
|
549
|
-
)
|
|
550
|
-
return bulk_request, cast(list[Any], operations)
|
|
551
|
-
|
|
552
540
|
def run_bulk_operation(
|
|
553
|
-
self, request: Request,
|
|
541
|
+
self, request: Request, operation: BulkOperation, resolve: Resolver
|
|
554
542
|
) -> tuple[dict[str, Any], Resource | None]:
|
|
555
543
|
"""Apply one operation of a bulk job.
|
|
556
544
|
|
|
545
|
+
An operation that failed its validation keeps its error, once its
|
|
546
|
+
references are resolved to locate it.
|
|
547
|
+
|
|
557
548
|
:return: The outcome of the operation, and the resource it created or updated.
|
|
558
549
|
"""
|
|
559
|
-
method = raw_attribute(payload, "method")
|
|
560
|
-
bulk_id = raw_attribute(payload, "bulkId")
|
|
561
550
|
result: dict[str, Any] = {
|
|
562
|
-
"method": method
|
|
563
|
-
|
|
564
|
-
else None,
|
|
565
|
-
"bulk_id": bulk_id if isinstance(bulk_id, str) else None,
|
|
551
|
+
"method": operation.method,
|
|
552
|
+
"bulk_id": operation.bulk_id,
|
|
566
553
|
}
|
|
567
554
|
|
|
568
555
|
try:
|
|
569
|
-
|
|
570
|
-
resource_type
|
|
571
|
-
if resource_id:
|
|
556
|
+
operation = resolve(operation)
|
|
557
|
+
resource_type = self.get_resource_type_by_endpoint(operation.endpoint or "")
|
|
558
|
+
if resource_type is not None and operation.resource_id:
|
|
572
559
|
result["location"] = urljoin(
|
|
573
|
-
request.url,
|
|
560
|
+
request.url,
|
|
561
|
+
f"{resource_type.endpoint.strip('/')}/{operation.resource_id}",
|
|
574
562
|
)
|
|
575
|
-
operation
|
|
576
|
-
|
|
563
|
+
if isinstance(operation.response, Error):
|
|
564
|
+
return {
|
|
565
|
+
**result,
|
|
566
|
+
"status": operation.status,
|
|
567
|
+
"response": operation.response,
|
|
568
|
+
}, None
|
|
569
|
+
resource = self.apply_bulk_operation(
|
|
570
|
+
cast(ResourceType, resource_type), operation
|
|
577
571
|
)
|
|
578
|
-
resource = self.apply_bulk_operation(resource_type, resource_id, operation)
|
|
579
572
|
except Exception as exception:
|
|
580
573
|
error = self.error_from(exception)
|
|
581
574
|
return {**result, "status": error.status, "response": error}, None
|
|
@@ -589,34 +582,15 @@ class SCIMApplication:
|
|
|
589
582
|
result["version"] = resource.meta.version
|
|
590
583
|
return result, resource
|
|
591
584
|
|
|
592
|
-
def get_bulk_target(self, payload: Any) -> tuple[ResourceType, str]:
|
|
593
|
-
"""Return the resource type and the resource identifier of a bulk operation path.
|
|
594
|
-
|
|
595
|
-
:raises NotFound: When the path does not start with a resource type endpoint.
|
|
596
|
-
"""
|
|
597
|
-
path = raw_attribute(payload, "path")
|
|
598
|
-
if not isinstance(path, str):
|
|
599
|
-
raise InvalidValueException(
|
|
600
|
-
detail="path is required for request operations"
|
|
601
|
-
)
|
|
602
|
-
|
|
603
|
-
endpoint, _, resource_id = path.lstrip("/").partition("/")
|
|
604
|
-
resource_type = self.get_resource_type_by_endpoint(endpoint)
|
|
605
|
-
if resource_type is None:
|
|
606
|
-
raise NotFound
|
|
607
|
-
return resource_type, resource_id
|
|
608
|
-
|
|
609
585
|
def apply_bulk_operation(
|
|
610
|
-
self,
|
|
611
|
-
resource_type: ResourceType,
|
|
612
|
-
resource_id: str,
|
|
613
|
-
operation: BulkOperation,
|
|
586
|
+
self, resource_type: ResourceType, operation: BulkOperation
|
|
614
587
|
) -> Resource | None:
|
|
615
588
|
"""Apply a validated bulk operation, and return the resource it acted on.
|
|
616
589
|
|
|
617
590
|
The data of the operation is already validated, and the resource
|
|
618
591
|
operations take it as it is.
|
|
619
592
|
"""
|
|
593
|
+
resource_id = operation.resource_id
|
|
620
594
|
if (operation.method == BulkOperation.Method.post) == bool(resource_id):
|
|
621
595
|
raise InvalidValueException(
|
|
622
596
|
detail="A POST path must target a resource type endpoint, other methods a resource"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scim2_server-0.3.0 → scim2_server-0.3.1}/scim2_server/resources/default-resource-types.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|