scim2-models 0.3.6__py3-none-any.whl → 0.4.0__py3-none-any.whl
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_models/__init__.py +52 -52
- scim2_models/annotations.py +104 -0
- scim2_models/attributes.py +57 -0
- scim2_models/base.py +195 -554
- scim2_models/constants.py +3 -3
- scim2_models/context.py +168 -0
- scim2_models/{rfc7644 → messages}/bulk.py +4 -4
- scim2_models/{rfc7644 → messages}/error.py +13 -13
- scim2_models/messages/list_response.py +75 -0
- scim2_models/messages/message.py +119 -0
- scim2_models/messages/patch_op.py +478 -0
- scim2_models/{rfc7644 → messages}/search_request.py +55 -6
- scim2_models/reference.py +82 -0
- scim2_models/{rfc7643 → resources}/enterprise_user.py +4 -4
- scim2_models/{rfc7643 → resources}/group.py +5 -5
- scim2_models/resources/resource.py +468 -0
- scim2_models/{rfc7643 → resources}/resource_type.py +13 -22
- scim2_models/{rfc7643 → resources}/schema.py +51 -45
- scim2_models/{rfc7643 → resources}/service_provider_config.py +7 -7
- scim2_models/{rfc7643 → resources}/user.py +9 -9
- scim2_models/scim_object.py +66 -0
- scim2_models/urn.py +109 -0
- scim2_models/utils.py +108 -6
- {scim2_models-0.3.6.dist-info → scim2_models-0.4.0.dist-info}/METADATA +1 -1
- scim2_models-0.4.0.dist-info/RECORD +30 -0
- scim2_models/rfc7643/resource.py +0 -355
- scim2_models/rfc7644/list_response.py +0 -136
- scim2_models/rfc7644/message.py +0 -10
- scim2_models/rfc7644/patch_op.py +0 -70
- scim2_models-0.3.6.dist-info/RECORD +0 -24
- /scim2_models/{rfc7643 → messages}/__init__.py +0 -0
- /scim2_models/{rfc7644 → resources}/__init__.py +0 -0
- {scim2_models-0.3.6.dist-info → scim2_models-0.4.0.dist-info}/WHEEL +0 -0
- {scim2_models-0.3.6.dist-info → scim2_models-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from inspect import isclass
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
from typing import Any
|
|
5
|
+
from typing import Generic
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
from pydantic import ValidationInfo
|
|
11
|
+
from pydantic import field_validator
|
|
12
|
+
from pydantic import model_validator
|
|
13
|
+
from typing_extensions import Self
|
|
14
|
+
|
|
15
|
+
from ..annotations import Mutability
|
|
16
|
+
from ..annotations import Required
|
|
17
|
+
from ..attributes import ComplexAttribute
|
|
18
|
+
from ..base import BaseModel
|
|
19
|
+
from ..context import Context
|
|
20
|
+
from ..resources.resource import Resource
|
|
21
|
+
from ..urn import _resolve_path_to_target
|
|
22
|
+
from ..utils import _extract_field_name
|
|
23
|
+
from ..utils import _find_field_name
|
|
24
|
+
from ..utils import _validate_scim_path_syntax
|
|
25
|
+
from .error import Error
|
|
26
|
+
from .message import Message
|
|
27
|
+
from .message import _get_resource_class
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T", bound=Resource)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PatchOperation(ComplexAttribute):
|
|
33
|
+
class Op(str, Enum):
|
|
34
|
+
replace_ = "replace"
|
|
35
|
+
remove = "remove"
|
|
36
|
+
add = "add"
|
|
37
|
+
|
|
38
|
+
op: Op
|
|
39
|
+
"""Each PATCH operation object MUST have exactly one "op" member, whose
|
|
40
|
+
value indicates the operation to perform and MAY be one of "add", "remove",
|
|
41
|
+
or "replace".
|
|
42
|
+
|
|
43
|
+
.. note::
|
|
44
|
+
|
|
45
|
+
For the sake of compatibility with Microsoft Entra,
|
|
46
|
+
despite :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`, op is case-insensitive.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
path: Optional[str] = None
|
|
50
|
+
"""The "path" attribute value is a String containing an attribute path
|
|
51
|
+
describing the target of the operation."""
|
|
52
|
+
|
|
53
|
+
def _validate_mutability(
|
|
54
|
+
self, resource_class: type[BaseModel], field_name: str
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Validate mutability constraints."""
|
|
57
|
+
# RFC 7644 Section 3.5.2: "Servers should be tolerant of schema extensions"
|
|
58
|
+
if field_name not in resource_class.model_fields:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
mutability = resource_class.get_field_annotation(field_name, Mutability)
|
|
62
|
+
|
|
63
|
+
# RFC 7643 Section 7: "Attributes with mutability 'readOnly' SHALL NOT be modified"
|
|
64
|
+
if mutability == Mutability.read_only and self.op in (
|
|
65
|
+
PatchOperation.Op.add,
|
|
66
|
+
PatchOperation.Op.replace_,
|
|
67
|
+
):
|
|
68
|
+
raise ValueError(Error.make_mutability_error().detail)
|
|
69
|
+
|
|
70
|
+
# RFC 7643 Section 7: "Attributes with mutability 'immutable' SHALL NOT be updated"
|
|
71
|
+
if mutability == Mutability.immutable and self.op == PatchOperation.Op.replace_:
|
|
72
|
+
raise ValueError(Error.make_mutability_error().detail)
|
|
73
|
+
|
|
74
|
+
def _validate_required_attribute(
|
|
75
|
+
self, resource_class: type[BaseModel], field_name: str
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Validate required attribute constraints for remove operations."""
|
|
78
|
+
# RFC 7644 Section 3.5.2.3: Only validate for remove operations
|
|
79
|
+
if self.op != PatchOperation.Op.remove:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
# RFC 7644 Section 3.5.2: "Servers should be tolerant of schema extensions"
|
|
83
|
+
if field_name not in resource_class.model_fields:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
required = resource_class.get_field_annotation(field_name, Required)
|
|
87
|
+
|
|
88
|
+
# RFC 7643 Section 7: "Required attributes SHALL NOT be removed"
|
|
89
|
+
if required == Required.true:
|
|
90
|
+
raise ValueError(Error.make_invalid_value_error().detail)
|
|
91
|
+
|
|
92
|
+
@model_validator(mode="after")
|
|
93
|
+
def validate_operation_requirements(self, info: ValidationInfo) -> Self:
|
|
94
|
+
"""Validate operation requirements according to RFC 7644."""
|
|
95
|
+
# Only validate in PATCH request context
|
|
96
|
+
scim_ctx = info.context.get("scim") if info.context else None
|
|
97
|
+
if scim_ctx != Context.RESOURCE_PATCH_REQUEST:
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
# RFC 7644 Section 3.5.2: "Path syntax validation according to ABNF grammar"
|
|
101
|
+
if self.path is not None and not _validate_scim_path_syntax(self.path):
|
|
102
|
+
raise ValueError(Error.make_invalid_path_error().detail)
|
|
103
|
+
|
|
104
|
+
# RFC 7644 Section 3.5.2.3: "Path is required for remove operations"
|
|
105
|
+
if self.path is None and self.op == PatchOperation.Op.remove:
|
|
106
|
+
raise ValueError(Error.make_invalid_path_error().detail)
|
|
107
|
+
|
|
108
|
+
# RFC 7644 Section 3.5.2.1: "Value is required for add operations"
|
|
109
|
+
if self.op == PatchOperation.Op.add and self.value is None:
|
|
110
|
+
raise ValueError(Error.make_invalid_value_error().detail)
|
|
111
|
+
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
value: Optional[Any] = None
|
|
115
|
+
|
|
116
|
+
@field_validator("op", mode="before")
|
|
117
|
+
@classmethod
|
|
118
|
+
def normalize_op(cls, v: Any) -> Any:
|
|
119
|
+
"""Ignore case for op.
|
|
120
|
+
|
|
121
|
+
This brings
|
|
122
|
+
`compatibility with Microsoft Entra <https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups#general>`_:
|
|
123
|
+
|
|
124
|
+
Don't require a case-sensitive match on structural elements in SCIM,
|
|
125
|
+
in particular PATCH op operation values, as defined in section 3.5.2.
|
|
126
|
+
Microsoft Entra ID emits the values of op as Add, Replace, and Remove.
|
|
127
|
+
"""
|
|
128
|
+
if isinstance(v, str):
|
|
129
|
+
return v.lower()
|
|
130
|
+
return v
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class PatchOp(Message, Generic[T]):
|
|
134
|
+
"""Patch Operation as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
|
|
135
|
+
|
|
136
|
+
Type parameter T is required and must be a concrete Resource subclass.
|
|
137
|
+
Usage: PatchOp[User], PatchOp[Group], etc.
|
|
138
|
+
|
|
139
|
+
.. note::
|
|
140
|
+
- Always use with a specific type parameter, e.g., PatchOp[User]
|
|
141
|
+
- PatchOp[Resource] is not allowed - use a concrete subclass instead
|
|
142
|
+
- Union types are not supported - use a specific resource type
|
|
143
|
+
- Using PatchOp without a type parameter raises TypeError
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __new__(cls, *args, **kwargs):
|
|
147
|
+
"""Create new PatchOp instance with type parameter validation.
|
|
148
|
+
|
|
149
|
+
Only handles the case of direct instantiation without type parameter (PatchOp()).
|
|
150
|
+
All type parameter validation is handled by __class_getitem__.
|
|
151
|
+
"""
|
|
152
|
+
if (
|
|
153
|
+
cls.__name__ == "PatchOp"
|
|
154
|
+
and not hasattr(cls, "__origin__")
|
|
155
|
+
and not hasattr(cls, "__args__")
|
|
156
|
+
):
|
|
157
|
+
raise TypeError(
|
|
158
|
+
"PatchOp requires a type parameter. "
|
|
159
|
+
"Use PatchOp[YourResourceType] instead of PatchOp. "
|
|
160
|
+
"Example: PatchOp[User], PatchOp[Group], etc."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return super().__new__(cls)
|
|
164
|
+
|
|
165
|
+
def __class_getitem__(cls, item):
|
|
166
|
+
"""Validate type parameter when creating parameterized type.
|
|
167
|
+
|
|
168
|
+
Ensures the type parameter is a concrete Resource subclass (not Resource itself).
|
|
169
|
+
Rejects invalid types (str, int, etc.) and Union types.
|
|
170
|
+
"""
|
|
171
|
+
# Check if type parameter is a concrete Resource subclass (not Resource itself)
|
|
172
|
+
if item is Resource:
|
|
173
|
+
raise TypeError(
|
|
174
|
+
"PatchOp requires a concrete Resource subclass, not Resource itself. "
|
|
175
|
+
"Use PatchOp[User], PatchOp[Group], etc. instead of PatchOp[Resource]."
|
|
176
|
+
)
|
|
177
|
+
if not (isclass(item) and issubclass(item, Resource) and item is not Resource):
|
|
178
|
+
raise TypeError(
|
|
179
|
+
f"PatchOp type parameter must be a concrete Resource subclass, got {item}. "
|
|
180
|
+
"Use PatchOp[User], PatchOp[Group], etc."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return super().__class_getitem__(item)
|
|
184
|
+
|
|
185
|
+
schemas: Annotated[list[str], Required.true] = [
|
|
186
|
+
"urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
operations: Annotated[Optional[list[PatchOperation]], Required.true] = Field(
|
|
190
|
+
None, serialization_alias="Operations", min_length=1
|
|
191
|
+
)
|
|
192
|
+
"""The body of an HTTP PATCH request MUST contain the attribute
|
|
193
|
+
"Operations", whose value is an array of one or more PATCH operations."""
|
|
194
|
+
|
|
195
|
+
@model_validator(mode="after")
|
|
196
|
+
def validate_operations(self) -> Self:
|
|
197
|
+
"""Validate operations against resource type metadata if available.
|
|
198
|
+
|
|
199
|
+
When PatchOp is used with a specific resource type (e.g., PatchOp[User]),
|
|
200
|
+
this validator will automatically check mutability and required constraints.
|
|
201
|
+
"""
|
|
202
|
+
resource_class = _get_resource_class(self)
|
|
203
|
+
if resource_class is None or not self.operations:
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
# RFC 7644 Section 3.5.2: "Validate each operation against schema constraints"
|
|
207
|
+
for operation in self.operations:
|
|
208
|
+
if operation.path is None:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
field_name = _extract_field_name(operation.path)
|
|
212
|
+
operation._validate_mutability(resource_class, field_name) # type: ignore[arg-type]
|
|
213
|
+
operation._validate_required_attribute(resource_class, field_name) # type: ignore[arg-type]
|
|
214
|
+
|
|
215
|
+
return self
|
|
216
|
+
|
|
217
|
+
def patch(self, resource: T) -> bool:
|
|
218
|
+
"""Apply all PATCH operations to the given SCIM resource in sequence.
|
|
219
|
+
|
|
220
|
+
The resource is modified in-place.
|
|
221
|
+
|
|
222
|
+
Each operation in the PatchOp is applied in order, modifying the resource in-place
|
|
223
|
+
according to :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. Supported operations are
|
|
224
|
+
"add", "replace", and "remove". If any operation modifies the resource, the method
|
|
225
|
+
returns True; otherwise, False.
|
|
226
|
+
|
|
227
|
+
:param resource: The SCIM resource to patch. This object is modified in-place.
|
|
228
|
+
:type resource: T
|
|
229
|
+
:return: True if the resource was modified by any operation, False otherwise.
|
|
230
|
+
:raises ValueError: If an operation is invalid (e.g., invalid path, forbidden mutation).
|
|
231
|
+
"""
|
|
232
|
+
if not self.operations:
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
modified = False
|
|
236
|
+
# RFC 7644 Section 3.5.2: "Apply each operation in sequence"
|
|
237
|
+
for operation in self.operations:
|
|
238
|
+
if self._apply_operation(resource, operation):
|
|
239
|
+
modified = True
|
|
240
|
+
|
|
241
|
+
return modified
|
|
242
|
+
|
|
243
|
+
def _apply_operation(self, resource: Resource, operation: PatchOperation) -> bool:
|
|
244
|
+
"""Apply a single patch operation to a resource.
|
|
245
|
+
|
|
246
|
+
:return: :data:`True` if the resource was modified, else :data:`False`.
|
|
247
|
+
"""
|
|
248
|
+
if operation.op in (PatchOperation.Op.add, PatchOperation.Op.replace_):
|
|
249
|
+
return self._apply_add_replace(resource, operation)
|
|
250
|
+
if operation.op == PatchOperation.Op.remove:
|
|
251
|
+
return self._apply_remove(resource, operation)
|
|
252
|
+
|
|
253
|
+
raise ValueError(Error.make_invalid_value_error().detail)
|
|
254
|
+
|
|
255
|
+
def _apply_add_replace(self, resource: Resource, operation: PatchOperation) -> bool:
|
|
256
|
+
"""Apply an add or replace operation."""
|
|
257
|
+
# RFC 7644 Section 3.5.2.1: "If path is specified, add/replace at that path"
|
|
258
|
+
if operation.path is not None:
|
|
259
|
+
return self._set_value_at_path(
|
|
260
|
+
resource,
|
|
261
|
+
operation.path,
|
|
262
|
+
operation.value,
|
|
263
|
+
is_add=operation.op == PatchOperation.Op.add,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# RFC 7644 Section 3.5.2.1: "If no path specified, add/replace at root level"
|
|
267
|
+
return self._apply_root_attributes(resource, operation.value)
|
|
268
|
+
|
|
269
|
+
def _apply_remove(self, resource: Resource, operation: PatchOperation) -> bool:
|
|
270
|
+
"""Apply a remove operation."""
|
|
271
|
+
# RFC 7644 Section 3.5.2.3: "Path is required for remove operations"
|
|
272
|
+
if operation.path is None:
|
|
273
|
+
raise ValueError(Error.make_invalid_path_error().detail)
|
|
274
|
+
|
|
275
|
+
# RFC 7644 Section 3.5.2.3: "If a value is specified, remove only that value"
|
|
276
|
+
if operation.value is not None:
|
|
277
|
+
return self._remove_specific_value(
|
|
278
|
+
resource, operation.path, operation.value
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return self._remove_value_at_path(resource, operation.path)
|
|
282
|
+
|
|
283
|
+
def _apply_root_attributes(self, resource: BaseModel, value: Any) -> bool:
|
|
284
|
+
"""Apply attributes to the resource root."""
|
|
285
|
+
if not isinstance(value, dict):
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
modified = False
|
|
289
|
+
for attr_name, val in value.items():
|
|
290
|
+
field_name = _find_field_name(type(resource), attr_name)
|
|
291
|
+
if not field_name:
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
old_value = getattr(resource, field_name)
|
|
295
|
+
if old_value != val:
|
|
296
|
+
setattr(resource, field_name, val)
|
|
297
|
+
modified = True
|
|
298
|
+
|
|
299
|
+
return modified
|
|
300
|
+
|
|
301
|
+
def _set_value_at_path(
|
|
302
|
+
self, resource: Resource, path: str, value: Any, is_add: bool
|
|
303
|
+
) -> bool:
|
|
304
|
+
"""Set a value at a specific path."""
|
|
305
|
+
target, attr_path = _resolve_path_to_target(resource, path)
|
|
306
|
+
|
|
307
|
+
if not attr_path or not target:
|
|
308
|
+
raise ValueError(Error.make_invalid_path_error().detail)
|
|
309
|
+
|
|
310
|
+
path_parts = attr_path.split(".")
|
|
311
|
+
if len(path_parts) == 1:
|
|
312
|
+
return self._set_simple_attribute(target, path_parts[0], value, is_add)
|
|
313
|
+
|
|
314
|
+
return self._set_complex_attribute(target, path_parts, value, is_add)
|
|
315
|
+
|
|
316
|
+
def _set_simple_attribute(
|
|
317
|
+
self, resource: BaseModel, attr_name: str, value: Any, is_add: bool
|
|
318
|
+
) -> bool:
|
|
319
|
+
"""Set a value on a simple (non-nested) attribute."""
|
|
320
|
+
field_name = _find_field_name(type(resource), attr_name)
|
|
321
|
+
if not field_name:
|
|
322
|
+
raise ValueError(Error.make_no_target_error().detail)
|
|
323
|
+
|
|
324
|
+
# RFC 7644 Section 3.5.2.1: "For multi-valued attributes, add operation appends values"
|
|
325
|
+
if is_add and self._is_multivalued_field(resource, field_name):
|
|
326
|
+
return self._handle_multivalued_add(resource, field_name, value)
|
|
327
|
+
|
|
328
|
+
old_value = getattr(resource, field_name)
|
|
329
|
+
if old_value == value:
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
setattr(resource, field_name, value)
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
def _set_complex_attribute(
|
|
336
|
+
self, resource: BaseModel, path_parts: list[str], value: Any, is_add: bool
|
|
337
|
+
) -> bool:
|
|
338
|
+
"""Set a value on a complex (nested) attribute."""
|
|
339
|
+
parent_attr = path_parts[0]
|
|
340
|
+
sub_path = ".".join(path_parts[1:])
|
|
341
|
+
|
|
342
|
+
parent_field_name = _find_field_name(type(resource), parent_attr)
|
|
343
|
+
if not parent_field_name:
|
|
344
|
+
raise ValueError(Error.make_no_target_error().detail)
|
|
345
|
+
|
|
346
|
+
parent_obj = getattr(resource, parent_field_name)
|
|
347
|
+
if parent_obj is None:
|
|
348
|
+
parent_obj = self._create_parent_object(resource, parent_field_name)
|
|
349
|
+
if parent_obj is None:
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
return self._set_value_at_path(parent_obj, sub_path, value, is_add)
|
|
353
|
+
|
|
354
|
+
def _is_multivalued_field(self, resource: BaseModel, field_name: str) -> bool:
|
|
355
|
+
"""Check if a field is multi-valued."""
|
|
356
|
+
return hasattr(resource, field_name) and type(resource).get_field_multiplicity(
|
|
357
|
+
field_name
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def _handle_multivalued_add(
|
|
361
|
+
self, resource: BaseModel, field_name: str, value: Any
|
|
362
|
+
) -> bool:
|
|
363
|
+
"""Handle adding values to a multi-valued attribute."""
|
|
364
|
+
current_list = getattr(resource, field_name) or []
|
|
365
|
+
|
|
366
|
+
# RFC 7644 Section 3.5.2.1: "Add operation appends values to multi-valued attributes"
|
|
367
|
+
if isinstance(value, list):
|
|
368
|
+
return self._add_multiple_values(resource, field_name, current_list, value)
|
|
369
|
+
|
|
370
|
+
return self._add_single_value(resource, field_name, current_list, value)
|
|
371
|
+
|
|
372
|
+
def _add_multiple_values(
|
|
373
|
+
self, resource: BaseModel, field_name: str, current_list: list, values: list
|
|
374
|
+
) -> bool:
|
|
375
|
+
"""Add multiple values to a multi-valued attribute."""
|
|
376
|
+
new_values = []
|
|
377
|
+
# RFC 7644 Section 3.5.2.1: "Do not add duplicate values"
|
|
378
|
+
for new_val in values:
|
|
379
|
+
if not self._value_exists_in_list(current_list, new_val):
|
|
380
|
+
new_values.append(new_val)
|
|
381
|
+
|
|
382
|
+
if not new_values:
|
|
383
|
+
return False
|
|
384
|
+
|
|
385
|
+
setattr(resource, field_name, current_list + new_values)
|
|
386
|
+
return True
|
|
387
|
+
|
|
388
|
+
def _add_single_value(
|
|
389
|
+
self, resource: BaseModel, field_name: str, current_list: list, value: Any
|
|
390
|
+
) -> bool:
|
|
391
|
+
"""Add a single value to a multi-valued attribute."""
|
|
392
|
+
# RFC 7644 Section 3.5.2.1: "Do not add duplicate values"
|
|
393
|
+
if self._value_exists_in_list(current_list, value):
|
|
394
|
+
return False
|
|
395
|
+
|
|
396
|
+
current_list.append(value)
|
|
397
|
+
setattr(resource, field_name, current_list)
|
|
398
|
+
return True
|
|
399
|
+
|
|
400
|
+
def _value_exists_in_list(self, current_list: list, new_value: Any) -> bool:
|
|
401
|
+
"""Check if a value already exists in a list."""
|
|
402
|
+
return any(self._values_match(item, new_value) for item in current_list)
|
|
403
|
+
|
|
404
|
+
def _create_parent_object(self, resource: BaseModel, parent_field_name: str) -> Any:
|
|
405
|
+
"""Create a parent object if it doesn't exist."""
|
|
406
|
+
parent_class = type(resource).get_field_root_type(parent_field_name)
|
|
407
|
+
if not parent_class or not isclass(parent_class):
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
parent_obj = parent_class()
|
|
411
|
+
setattr(resource, parent_field_name, parent_obj)
|
|
412
|
+
return parent_obj
|
|
413
|
+
|
|
414
|
+
def _remove_value_at_path(self, resource: Resource, path: str) -> bool:
|
|
415
|
+
"""Remove a value at a specific path."""
|
|
416
|
+
target, attr_path = _resolve_path_to_target(resource, path)
|
|
417
|
+
|
|
418
|
+
# RFC 7644 Section 3.5.2.3: "Path must resolve to a valid attribute"
|
|
419
|
+
if not attr_path or not target:
|
|
420
|
+
raise ValueError(Error.make_invalid_path_error().detail)
|
|
421
|
+
|
|
422
|
+
parent_attr, *path_parts = attr_path.split(".")
|
|
423
|
+
field_name = _find_field_name(type(target), parent_attr)
|
|
424
|
+
if not field_name:
|
|
425
|
+
raise ValueError(Error.make_no_target_error().detail)
|
|
426
|
+
parent_obj = getattr(target, field_name)
|
|
427
|
+
|
|
428
|
+
if parent_obj is None:
|
|
429
|
+
return False
|
|
430
|
+
|
|
431
|
+
# RFC 7644 Section 3.5.2.3: "Remove entire attribute if no sub-path"
|
|
432
|
+
if not path_parts:
|
|
433
|
+
setattr(target, field_name, None)
|
|
434
|
+
return True
|
|
435
|
+
|
|
436
|
+
sub_path = ".".join(path_parts)
|
|
437
|
+
return self._remove_value_at_path(parent_obj, sub_path)
|
|
438
|
+
|
|
439
|
+
def _remove_specific_value(
|
|
440
|
+
self, resource: Resource, path: str, value_to_remove: Any
|
|
441
|
+
) -> bool:
|
|
442
|
+
"""Remove a specific value from a multi-valued attribute."""
|
|
443
|
+
target, attr_path = _resolve_path_to_target(resource, path)
|
|
444
|
+
|
|
445
|
+
# RFC 7644 Section 3.5.2.3: "Path must resolve to a valid attribute"
|
|
446
|
+
if not attr_path or not target:
|
|
447
|
+
raise ValueError(Error.make_invalid_path_error().detail)
|
|
448
|
+
|
|
449
|
+
field_name = _find_field_name(type(target), attr_path)
|
|
450
|
+
if not field_name:
|
|
451
|
+
raise ValueError(Error.make_no_target_error().detail)
|
|
452
|
+
|
|
453
|
+
current_list = getattr(target, field_name)
|
|
454
|
+
if not isinstance(current_list, list):
|
|
455
|
+
return False
|
|
456
|
+
|
|
457
|
+
new_list = []
|
|
458
|
+
modified = False
|
|
459
|
+
# RFC 7644 Section 3.5.2.3: "Remove matching values from multi-valued attributes"
|
|
460
|
+
for item in current_list:
|
|
461
|
+
if not self._values_match(item, value_to_remove):
|
|
462
|
+
new_list.append(item)
|
|
463
|
+
else:
|
|
464
|
+
modified = True
|
|
465
|
+
|
|
466
|
+
if modified:
|
|
467
|
+
setattr(target, field_name, new_list if new_list else None)
|
|
468
|
+
return True
|
|
469
|
+
|
|
470
|
+
return False
|
|
471
|
+
|
|
472
|
+
def _values_match(self, value1: Any, value2: Any) -> bool:
|
|
473
|
+
"""Check if two values match, converting BaseModel to dict for comparison."""
|
|
474
|
+
|
|
475
|
+
def to_dict(value):
|
|
476
|
+
return value.model_dump() if isinstance(value, BaseModel) else value
|
|
477
|
+
|
|
478
|
+
return to_dict(value1) == to_dict(value2)
|
|
@@ -5,7 +5,9 @@ from typing import Optional
|
|
|
5
5
|
from pydantic import field_validator
|
|
6
6
|
from pydantic import model_validator
|
|
7
7
|
|
|
8
|
-
from ..
|
|
8
|
+
from ..annotations import Required
|
|
9
|
+
from ..utils import _validate_scim_path_syntax
|
|
10
|
+
from .error import Error
|
|
9
11
|
from .message import Message
|
|
10
12
|
|
|
11
13
|
|
|
@@ -24,10 +26,38 @@ class SearchRequest(Message):
|
|
|
24
26
|
attributes to return in the response, overriding the set of attributes that
|
|
25
27
|
would be returned by default."""
|
|
26
28
|
|
|
29
|
+
@field_validator("attributes")
|
|
30
|
+
@classmethod
|
|
31
|
+
def validate_attributes_syntax(cls, v: Optional[list[str]]) -> Optional[list[str]]:
|
|
32
|
+
"""Validate syntax of attribute paths."""
|
|
33
|
+
if v is None:
|
|
34
|
+
return v
|
|
35
|
+
|
|
36
|
+
for attr in v:
|
|
37
|
+
if not _validate_scim_path_syntax(attr):
|
|
38
|
+
raise ValueError(Error.make_invalid_path_error().detail)
|
|
39
|
+
|
|
40
|
+
return v
|
|
41
|
+
|
|
27
42
|
excluded_attributes: Optional[list[str]] = None
|
|
28
43
|
"""A multi-valued list of strings indicating the names of resource
|
|
29
44
|
attributes to be removed from the default set of attributes to return."""
|
|
30
45
|
|
|
46
|
+
@field_validator("excluded_attributes")
|
|
47
|
+
@classmethod
|
|
48
|
+
def validate_excluded_attributes_syntax(
|
|
49
|
+
cls, v: Optional[list[str]]
|
|
50
|
+
) -> Optional[list[str]]:
|
|
51
|
+
"""Validate syntax of excluded attribute paths."""
|
|
52
|
+
if v is None:
|
|
53
|
+
return v
|
|
54
|
+
|
|
55
|
+
for attr in v:
|
|
56
|
+
if not _validate_scim_path_syntax(attr):
|
|
57
|
+
raise ValueError(Error.make_invalid_path_error().detail)
|
|
58
|
+
|
|
59
|
+
return v
|
|
60
|
+
|
|
31
61
|
filter: Optional[str] = None
|
|
32
62
|
"""The filter string used to request a subset of resources."""
|
|
33
63
|
|
|
@@ -35,6 +65,25 @@ class SearchRequest(Message):
|
|
|
35
65
|
"""A string indicating the attribute whose value SHALL be used to order the
|
|
36
66
|
returned responses."""
|
|
37
67
|
|
|
68
|
+
@field_validator("sort_by")
|
|
69
|
+
@classmethod
|
|
70
|
+
def validate_sort_by_syntax(cls, v: Optional[str]) -> Optional[str]:
|
|
71
|
+
"""Validate syntax of sort_by attribute path.
|
|
72
|
+
|
|
73
|
+
:param v: The sort_by attribute path to validate
|
|
74
|
+
:type v: Optional[str]
|
|
75
|
+
:return: The validated sort_by attribute path
|
|
76
|
+
:rtype: Optional[str]
|
|
77
|
+
:raises ValueError: If sort_by attribute path has invalid syntax
|
|
78
|
+
"""
|
|
79
|
+
if v is None:
|
|
80
|
+
return v
|
|
81
|
+
|
|
82
|
+
if not _validate_scim_path_syntax(v):
|
|
83
|
+
raise ValueError(Error.make_invalid_path_error().detail)
|
|
84
|
+
|
|
85
|
+
return v
|
|
86
|
+
|
|
38
87
|
class SortOrder(str, Enum):
|
|
39
88
|
ascending = "ascending"
|
|
40
89
|
descending = "descending"
|
|
@@ -48,7 +97,7 @@ class SearchRequest(Message):
|
|
|
48
97
|
|
|
49
98
|
@field_validator("start_index")
|
|
50
99
|
@classmethod
|
|
51
|
-
def start_index_floor(cls, value: int) -> int:
|
|
100
|
+
def start_index_floor(cls, value: Optional[int]) -> Optional[int]:
|
|
52
101
|
"""According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>, start_index values less than 1 are interpreted as 1.
|
|
53
102
|
|
|
54
103
|
A value less than 1 SHALL be interpreted as 1.
|
|
@@ -61,7 +110,7 @@ class SearchRequest(Message):
|
|
|
61
110
|
|
|
62
111
|
@field_validator("count")
|
|
63
112
|
@classmethod
|
|
64
|
-
def count_floor(cls, value: int) -> int:
|
|
113
|
+
def count_floor(cls, value: Optional[int]) -> Optional[int]:
|
|
65
114
|
"""According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>, count values less than 0 are interpreted as 0.
|
|
66
115
|
|
|
67
116
|
A negative value SHALL be interpreted as 0.
|
|
@@ -69,7 +118,7 @@ class SearchRequest(Message):
|
|
|
69
118
|
return None if value is None else max(0, value)
|
|
70
119
|
|
|
71
120
|
@model_validator(mode="after")
|
|
72
|
-
def attributes_validator(self):
|
|
121
|
+
def attributes_validator(self) -> "SearchRequest":
|
|
73
122
|
if self.attributes and self.excluded_attributes:
|
|
74
123
|
raise ValueError(
|
|
75
124
|
"'attributes' and 'excluded_attributes' are mutually exclusive"
|
|
@@ -78,12 +127,12 @@ class SearchRequest(Message):
|
|
|
78
127
|
return self
|
|
79
128
|
|
|
80
129
|
@property
|
|
81
|
-
def start_index_0(self):
|
|
130
|
+
def start_index_0(self) -> Optional[int]:
|
|
82
131
|
"""The 0 indexed start index."""
|
|
83
132
|
return self.start_index - 1 if self.start_index is not None else None
|
|
84
133
|
|
|
85
134
|
@property
|
|
86
|
-
def stop_index_0(self):
|
|
135
|
+
def stop_index_0(self) -> Optional[int]:
|
|
87
136
|
"""The 0 indexed stop index."""
|
|
88
137
|
return (
|
|
89
138
|
self.start_index_0 + self.count
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from collections import UserString
|
|
2
|
+
from typing import Any
|
|
3
|
+
from typing import Generic
|
|
4
|
+
from typing import TypeVar
|
|
5
|
+
from typing import get_args
|
|
6
|
+
from typing import get_origin
|
|
7
|
+
|
|
8
|
+
from pydantic import GetCoreSchemaHandler
|
|
9
|
+
from pydantic_core import core_schema
|
|
10
|
+
from typing_extensions import NewType
|
|
11
|
+
|
|
12
|
+
from .utils import UNION_TYPES
|
|
13
|
+
|
|
14
|
+
ReferenceTypes = TypeVar("ReferenceTypes")
|
|
15
|
+
|
|
16
|
+
URIReference = NewType("URIReference", str)
|
|
17
|
+
ExternalReference = NewType("ExternalReference", str)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Reference(UserString, Generic[ReferenceTypes]):
|
|
21
|
+
"""Reference type as defined in :rfc:`RFC7643 §2.3.7 <7643#section-2.3.7>`.
|
|
22
|
+
|
|
23
|
+
References can take different type parameters:
|
|
24
|
+
|
|
25
|
+
- Any :class:`~scim2_models.Resource` subtype, or :class:`~typing.ForwardRef` of a Resource subtype, or :data:`~typing.Union` of those,
|
|
26
|
+
- :data:`~scim2_models.ExternalReference`
|
|
27
|
+
- :data:`~scim2_models.URIReference`
|
|
28
|
+
|
|
29
|
+
Examples
|
|
30
|
+
--------
|
|
31
|
+
|
|
32
|
+
.. code-block:: python
|
|
33
|
+
|
|
34
|
+
class Foobar(Resource):
|
|
35
|
+
bff: Reference[User]
|
|
36
|
+
managers: Reference[Union["User", "Group"]]
|
|
37
|
+
photo: Reference[ExternalReference]
|
|
38
|
+
website: Reference[URIReference]
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def __get_pydantic_core_schema__(
|
|
44
|
+
cls,
|
|
45
|
+
_source: type[Any],
|
|
46
|
+
_handler: GetCoreSchemaHandler,
|
|
47
|
+
) -> core_schema.CoreSchema:
|
|
48
|
+
return core_schema.no_info_after_validator_function(
|
|
49
|
+
cls._validate,
|
|
50
|
+
core_schema.union_schema(
|
|
51
|
+
[core_schema.str_schema(), core_schema.is_instance_schema(cls)]
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def _validate(cls, input_value: Any, /) -> str:
|
|
57
|
+
return str(input_value)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def get_types(cls, type_annotation: Any) -> list[str]:
|
|
61
|
+
"""Get reference types from a type annotation.
|
|
62
|
+
|
|
63
|
+
:param type_annotation: Type annotation to extract reference types from
|
|
64
|
+
:type type_annotation: Any
|
|
65
|
+
:return: List of reference type strings
|
|
66
|
+
:rtype: list[str]
|
|
67
|
+
"""
|
|
68
|
+
first_arg = get_args(type_annotation)[0]
|
|
69
|
+
types = (
|
|
70
|
+
get_args(first_arg) if get_origin(first_arg) in UNION_TYPES else [first_arg]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def serialize_ref_type(ref_type: Any) -> str:
|
|
74
|
+
if ref_type == URIReference:
|
|
75
|
+
return "uri"
|
|
76
|
+
|
|
77
|
+
elif ref_type == ExternalReference:
|
|
78
|
+
return "external"
|
|
79
|
+
|
|
80
|
+
return str(get_args(ref_type)[0])
|
|
81
|
+
|
|
82
|
+
return list(map(serialize_ref_type, types))
|
|
@@ -4,10 +4,10 @@ from typing import Optional
|
|
|
4
4
|
|
|
5
5
|
from pydantic import Field
|
|
6
6
|
|
|
7
|
-
from ..
|
|
8
|
-
from ..
|
|
9
|
-
from ..
|
|
10
|
-
from ..
|
|
7
|
+
from ..annotations import Mutability
|
|
8
|
+
from ..annotations import Required
|
|
9
|
+
from ..attributes import ComplexAttribute
|
|
10
|
+
from ..reference import Reference
|
|
11
11
|
from .resource import Extension
|
|
12
12
|
|
|
13
13
|
|