scim2-models 0.5.2__py3-none-any.whl → 0.6.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 +32 -0
- scim2_models/attributes.py +5 -1
- scim2_models/base.py +52 -8
- scim2_models/exceptions.py +265 -0
- scim2_models/messages/bulk.py +3 -7
- scim2_models/messages/error.py +184 -15
- scim2_models/messages/list_response.py +2 -5
- scim2_models/messages/message.py +4 -4
- scim2_models/messages/patch_op.py +127 -261
- scim2_models/messages/search_request.py +9 -57
- scim2_models/path.py +731 -0
- scim2_models/reference.py +139 -48
- scim2_models/resources/enterprise_user.py +10 -8
- scim2_models/resources/group.py +9 -7
- scim2_models/resources/resource.py +110 -23
- scim2_models/resources/resource_type.py +15 -14
- scim2_models/resources/schema.py +15 -20
- scim2_models/resources/service_provider_config.py +10 -13
- scim2_models/resources/user.py +13 -10
- scim2_models/scim_object.py +84 -1
- scim2_models/utils.py +0 -140
- {scim2_models-0.5.2.dist-info → scim2_models-0.6.0.dist-info}/METADATA +2 -2
- scim2_models-0.6.0.dist-info/RECORD +30 -0
- scim2_models/urn.py +0 -126
- scim2_models-0.5.2.dist-info/RECORD +0 -29
- {scim2_models-0.5.2.dist-info → scim2_models-0.6.0.dist-info}/WHEEL +0 -0
|
@@ -5,6 +5,7 @@ from typing import Any
|
|
|
5
5
|
from typing import Generic
|
|
6
6
|
from typing import TypeVar
|
|
7
7
|
|
|
8
|
+
from pydantic import BaseModel as PydanticBaseModel
|
|
8
9
|
from pydantic import Field
|
|
9
10
|
from pydantic import ValidationInfo
|
|
10
11
|
from pydantic import field_validator
|
|
@@ -14,22 +15,20 @@ from typing_extensions import Self
|
|
|
14
15
|
from ..annotations import Mutability
|
|
15
16
|
from ..annotations import Required
|
|
16
17
|
from ..attributes import ComplexAttribute
|
|
17
|
-
from ..base import BaseModel
|
|
18
18
|
from ..context import Context
|
|
19
|
+
from ..exceptions import InvalidValueException
|
|
20
|
+
from ..exceptions import MutabilityException
|
|
21
|
+
from ..exceptions import NoTargetException
|
|
22
|
+
from ..path import URN
|
|
23
|
+
from ..path import Path
|
|
19
24
|
from ..resources.resource import Resource
|
|
20
|
-
from ..urn import _resolve_path_to_target
|
|
21
|
-
from ..utils import _extract_field_name
|
|
22
|
-
from ..utils import _find_field_name
|
|
23
|
-
from ..utils import _get_path_parts
|
|
24
|
-
from ..utils import _validate_scim_path_syntax
|
|
25
|
-
from .error import Error
|
|
26
25
|
from .message import Message
|
|
27
26
|
from .message import _get_resource_class
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
ResourceT = TypeVar("ResourceT", bound=Resource[Any])
|
|
30
29
|
|
|
31
30
|
|
|
32
|
-
class PatchOperation(ComplexAttribute):
|
|
31
|
+
class PatchOperation(ComplexAttribute, Generic[ResourceT]):
|
|
33
32
|
class Op(str, Enum):
|
|
34
33
|
replace_ = "replace"
|
|
35
34
|
remove = "remove"
|
|
@@ -46,7 +45,7 @@ class PatchOperation(ComplexAttribute):
|
|
|
46
45
|
despite :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`, op is case-insensitive.
|
|
47
46
|
"""
|
|
48
47
|
|
|
49
|
-
path:
|
|
48
|
+
path: Path[ResourceT] | None = None
|
|
50
49
|
"""The "path" attribute value is a String containing an attribute path
|
|
51
50
|
describing the target of the operation."""
|
|
52
51
|
|
|
@@ -65,11 +64,15 @@ class PatchOperation(ComplexAttribute):
|
|
|
65
64
|
PatchOperation.Op.add,
|
|
66
65
|
PatchOperation.Op.replace_,
|
|
67
66
|
):
|
|
68
|
-
raise
|
|
67
|
+
raise MutabilityException(
|
|
68
|
+
attribute=field_name, mutability="readOnly", operation=self.op.value
|
|
69
|
+
).as_pydantic_error()
|
|
69
70
|
|
|
70
71
|
# RFC 7643 Section 7: "Attributes with mutability 'immutable' SHALL NOT be updated"
|
|
71
72
|
if mutability == Mutability.immutable and self.op == PatchOperation.Op.replace_:
|
|
72
|
-
raise
|
|
73
|
+
raise MutabilityException(
|
|
74
|
+
attribute=field_name, mutability="immutable", operation=self.op.value
|
|
75
|
+
).as_pydantic_error()
|
|
73
76
|
|
|
74
77
|
def _validate_required_attribute(
|
|
75
78
|
self, resource_class: type[Resource[Any]], field_name: str
|
|
@@ -87,7 +90,9 @@ class PatchOperation(ComplexAttribute):
|
|
|
87
90
|
|
|
88
91
|
# RFC 7643 Section 7: "Required attributes SHALL NOT be removed"
|
|
89
92
|
if required == Required.true:
|
|
90
|
-
raise
|
|
93
|
+
raise InvalidValueException(
|
|
94
|
+
detail="required attribute cannot be removed", attribute=field_name
|
|
95
|
+
).as_pydantic_error()
|
|
91
96
|
|
|
92
97
|
@model_validator(mode="after")
|
|
93
98
|
def validate_operation_requirements(self, info: ValidationInfo) -> Self:
|
|
@@ -97,17 +102,18 @@ class PatchOperation(ComplexAttribute):
|
|
|
97
102
|
if scim_ctx != Context.RESOURCE_PATCH_REQUEST:
|
|
98
103
|
return self
|
|
99
104
|
|
|
100
|
-
# RFC 7644 Section 3.5.2: "
|
|
101
|
-
|
|
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
|
+
# RFC 7644 Section 3.5.2.2: "If 'path' is unspecified, the operation
|
|
106
|
+
# fails with HTTP status code 400 and a 'scimType' error of 'noTarget'"
|
|
105
107
|
if self.path is None and self.op == PatchOperation.Op.remove:
|
|
106
|
-
raise
|
|
108
|
+
raise NoTargetException(
|
|
109
|
+
detail="Remove operation requires a path"
|
|
110
|
+
).as_pydantic_error()
|
|
107
111
|
|
|
108
112
|
# RFC 7644 Section 3.5.2.1: "Value is required for add operations"
|
|
109
113
|
if self.op == PatchOperation.Op.add and self.value is None:
|
|
110
|
-
raise
|
|
114
|
+
raise InvalidValueException(
|
|
115
|
+
detail="value is required for add operations"
|
|
116
|
+
).as_pydantic_error()
|
|
111
117
|
|
|
112
118
|
return self
|
|
113
119
|
|
|
@@ -130,10 +136,10 @@ class PatchOperation(ComplexAttribute):
|
|
|
130
136
|
return v
|
|
131
137
|
|
|
132
138
|
|
|
133
|
-
class PatchOp(Message, Generic[
|
|
139
|
+
class PatchOp(Message, Generic[ResourceT]):
|
|
134
140
|
"""Patch Operation as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
|
|
135
141
|
|
|
136
|
-
Type parameter
|
|
142
|
+
Type parameter ResourceT is required and must be a concrete Resource subclass.
|
|
137
143
|
Usage: PatchOp[User], PatchOp[Group], etc.
|
|
138
144
|
|
|
139
145
|
.. note::
|
|
@@ -205,12 +211,10 @@ class PatchOp(Message, Generic[T]):
|
|
|
205
211
|
|
|
206
212
|
return super().__class_getitem__(typevar_values)
|
|
207
213
|
|
|
208
|
-
|
|
209
|
-
"urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
|
210
|
-
]
|
|
214
|
+
__schema__ = URN("urn:ietf:params:scim:api:messages:2.0:PatchOp")
|
|
211
215
|
|
|
212
|
-
operations: Annotated[list[PatchOperation] | None, Required.true] =
|
|
213
|
-
None, serialization_alias="Operations", min_length=1
|
|
216
|
+
operations: Annotated[list[PatchOperation[ResourceT]] | None, Required.true] = (
|
|
217
|
+
Field(None, serialization_alias="Operations", min_length=1)
|
|
214
218
|
)
|
|
215
219
|
"""The body of an HTTP PATCH request MUST contain the attribute
|
|
216
220
|
"Operations", whose value is an array of one or more PATCH operations."""
|
|
@@ -225,7 +229,9 @@ class PatchOp(Message, Generic[T]):
|
|
|
225
229
|
# RFC 7644: The body of an HTTP PATCH request MUST contain the attribute "Operations"
|
|
226
230
|
scim_ctx = info.context.get("scim") if info.context else None
|
|
227
231
|
if scim_ctx == Context.RESOURCE_PATCH_REQUEST and self.operations is None:
|
|
228
|
-
raise
|
|
232
|
+
raise InvalidValueException(
|
|
233
|
+
detail="operations attribute is required"
|
|
234
|
+
).as_pydantic_error()
|
|
229
235
|
|
|
230
236
|
resource_class = _get_resource_class(self)
|
|
231
237
|
if resource_class is None or not self.operations:
|
|
@@ -236,13 +242,13 @@ class PatchOp(Message, Generic[T]):
|
|
|
236
242
|
if operation.path is None:
|
|
237
243
|
continue
|
|
238
244
|
|
|
239
|
-
field_name =
|
|
245
|
+
field_name = operation.path.parts[0] if operation.path.parts else None
|
|
240
246
|
operation._validate_mutability(resource_class, field_name) # type: ignore[arg-type]
|
|
241
247
|
operation._validate_required_attribute(resource_class, field_name) # type: ignore[arg-type]
|
|
242
248
|
|
|
243
249
|
return self
|
|
244
250
|
|
|
245
|
-
def patch(self, resource:
|
|
251
|
+
def patch(self, resource: ResourceT) -> bool:
|
|
246
252
|
"""Apply all PATCH operations to the given SCIM resource in sequence.
|
|
247
253
|
|
|
248
254
|
The resource is modified in-place.
|
|
@@ -252,10 +258,14 @@ class PatchOp(Message, Generic[T]):
|
|
|
252
258
|
"add", "replace", and "remove". If any operation modifies the resource, the method
|
|
253
259
|
returns True; otherwise, False.
|
|
254
260
|
|
|
261
|
+
Per :rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`, when an operation sets a value's
|
|
262
|
+
``primary`` sub-attribute to ``True``, any other values in the same multi-valued
|
|
263
|
+
attribute will have their ``primary`` set to ``False`` automatically.
|
|
264
|
+
|
|
255
265
|
:param resource: The SCIM resource to patch. This object is modified in-place.
|
|
256
|
-
:type resource: T
|
|
257
266
|
:return: True if the resource was modified by any operation, False otherwise.
|
|
258
|
-
:raises
|
|
267
|
+
:raises InvalidValueException: If multiple values are marked as primary in a single
|
|
268
|
+
operation, or if multiple primary values already exist before the patch.
|
|
259
269
|
"""
|
|
260
270
|
if not self.operations:
|
|
261
271
|
return False
|
|
@@ -269,7 +279,7 @@ class PatchOp(Message, Generic[T]):
|
|
|
269
279
|
return modified
|
|
270
280
|
|
|
271
281
|
def _apply_operation(
|
|
272
|
-
self, resource: Resource[Any], operation: PatchOperation
|
|
282
|
+
self, resource: Resource[Any], operation: PatchOperation[ResourceT]
|
|
273
283
|
) -> bool:
|
|
274
284
|
"""Apply a single patch operation to a resource.
|
|
275
285
|
|
|
@@ -280,257 +290,113 @@ class PatchOp(Message, Generic[T]):
|
|
|
280
290
|
if operation.op == PatchOperation.Op.remove:
|
|
281
291
|
return self._apply_remove(resource, operation)
|
|
282
292
|
|
|
283
|
-
raise
|
|
293
|
+
raise InvalidValueException(detail=f"unsupported operation: {operation.op}")
|
|
284
294
|
|
|
285
295
|
def _apply_add_replace(
|
|
286
|
-
self, resource: Resource[Any], operation: PatchOperation
|
|
296
|
+
self, resource: Resource[Any], operation: PatchOperation[ResourceT]
|
|
287
297
|
) -> bool:
|
|
288
298
|
"""Apply an add or replace operation."""
|
|
289
|
-
|
|
290
|
-
if operation.path is not None:
|
|
291
|
-
return self._set_value_at_path(
|
|
292
|
-
resource,
|
|
293
|
-
operation.path,
|
|
294
|
-
operation.value,
|
|
295
|
-
is_add=operation.op == PatchOperation.Op.add,
|
|
296
|
-
)
|
|
297
|
-
|
|
298
|
-
# RFC 7644 Section 3.5.2.1: "If no path specified, add/replace at root level"
|
|
299
|
-
return self._apply_root_attributes(resource, operation.value)
|
|
300
|
-
|
|
301
|
-
def _apply_remove(self, resource: Resource[Any], operation: PatchOperation) -> bool:
|
|
302
|
-
"""Apply a remove operation."""
|
|
303
|
-
# RFC 7644 Section 3.5.2.3: "Path is required for remove operations"
|
|
304
|
-
if operation.path is None:
|
|
305
|
-
raise ValueError(Error.make_invalid_path_error().detail)
|
|
306
|
-
|
|
307
|
-
# RFC 7644 Section 3.5.2.3: "If a value is specified, remove only that value"
|
|
308
|
-
if operation.value is not None:
|
|
309
|
-
return self._remove_specific_value(
|
|
310
|
-
resource, operation.path, operation.value
|
|
311
|
-
)
|
|
312
|
-
|
|
313
|
-
return self._remove_value_at_path(resource, operation.path)
|
|
314
|
-
|
|
315
|
-
@classmethod
|
|
316
|
-
def _apply_root_attributes(cls, resource: BaseModel, value: Any) -> bool:
|
|
317
|
-
"""Apply attributes to the resource root."""
|
|
318
|
-
if not isinstance(value, dict):
|
|
319
|
-
return False
|
|
299
|
+
before_state = self._capture_primary_state(resource)
|
|
320
300
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
301
|
+
path = operation.path if operation.path is not None else Path("")
|
|
302
|
+
modified = path.set(
|
|
303
|
+
resource, # type: ignore[arg-type]
|
|
304
|
+
operation.value,
|
|
305
|
+
is_add=operation.op == PatchOperation.Op.add,
|
|
306
|
+
)
|
|
326
307
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
setattr(resource, field_name, val)
|
|
330
|
-
modified = True
|
|
308
|
+
if modified:
|
|
309
|
+
self._normalize_primary_after_patch(resource, before_state)
|
|
331
310
|
|
|
332
311
|
return modified
|
|
333
312
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if not target:
|
|
342
|
-
raise ValueError(Error.make_invalid_path_error().detail)
|
|
343
|
-
|
|
344
|
-
if not attr_path:
|
|
345
|
-
if not isinstance(value, dict):
|
|
346
|
-
raise ValueError(Error.make_invalid_path_error().detail)
|
|
347
|
-
|
|
348
|
-
updated_data = {**target.model_dump(), **value}
|
|
349
|
-
updated_target = type(target).model_validate(updated_data)
|
|
350
|
-
target.__dict__.update(updated_target.__dict__)
|
|
351
|
-
return True
|
|
352
|
-
|
|
353
|
-
path_parts = _get_path_parts(attr_path)
|
|
354
|
-
if len(path_parts) == 1:
|
|
355
|
-
return cls._set_simple_attribute(target, path_parts[0], value, is_add)
|
|
356
|
-
|
|
357
|
-
return cls._set_complex_attribute(target, path_parts, value, is_add)
|
|
358
|
-
|
|
359
|
-
@classmethod
|
|
360
|
-
def _set_simple_attribute(
|
|
361
|
-
cls, resource: BaseModel, attr_name: str, value: Any, is_add: bool
|
|
362
|
-
) -> bool:
|
|
363
|
-
"""Set a value on a simple (non-nested) attribute."""
|
|
364
|
-
field_name = _find_field_name(type(resource), attr_name)
|
|
365
|
-
if not field_name:
|
|
366
|
-
raise ValueError(Error.make_no_target_error().detail)
|
|
367
|
-
|
|
368
|
-
# RFC 7644 Section 3.5.2.1: "For multi-valued attributes, add operation appends values"
|
|
369
|
-
if is_add and cls._is_multivalued_field(resource, field_name):
|
|
370
|
-
return cls._handle_multivalued_add(resource, field_name, value)
|
|
371
|
-
|
|
372
|
-
old_value = getattr(resource, field_name)
|
|
373
|
-
if old_value == value:
|
|
374
|
-
return False
|
|
375
|
-
|
|
376
|
-
setattr(resource, field_name, value)
|
|
377
|
-
return True
|
|
378
|
-
|
|
379
|
-
@classmethod
|
|
380
|
-
def _set_complex_attribute(
|
|
381
|
-
cls, resource: BaseModel, path_parts: list[str], value: Any, is_add: bool
|
|
382
|
-
) -> bool:
|
|
383
|
-
"""Set a value on a complex (nested) attribute."""
|
|
384
|
-
parent_attr = path_parts[0]
|
|
385
|
-
sub_path = ".".join(path_parts[1:])
|
|
386
|
-
|
|
387
|
-
parent_field_name = _find_field_name(type(resource), parent_attr)
|
|
388
|
-
if not parent_field_name:
|
|
389
|
-
raise ValueError(Error.make_no_target_error().detail)
|
|
390
|
-
|
|
391
|
-
parent_obj = getattr(resource, parent_field_name)
|
|
392
|
-
if parent_obj is None:
|
|
393
|
-
parent_obj = cls._create_parent_object(resource, parent_field_name)
|
|
394
|
-
if parent_obj is None:
|
|
395
|
-
return False
|
|
396
|
-
|
|
397
|
-
return cls._set_value_at_path(parent_obj, sub_path, value, is_add)
|
|
398
|
-
|
|
399
|
-
@classmethod
|
|
400
|
-
def _is_multivalued_field(cls, resource: BaseModel, field_name: str) -> bool:
|
|
401
|
-
"""Check if a field is multi-valued."""
|
|
402
|
-
return hasattr(resource, field_name) and type(resource).get_field_multiplicity(
|
|
403
|
-
field_name
|
|
404
|
-
)
|
|
405
|
-
|
|
406
|
-
@classmethod
|
|
407
|
-
def _handle_multivalued_add(
|
|
408
|
-
cls, resource: BaseModel, field_name: str, value: Any
|
|
409
|
-
) -> bool:
|
|
410
|
-
"""Handle adding values to a multi-valued attribute."""
|
|
411
|
-
current_list = getattr(resource, field_name) or []
|
|
313
|
+
def _capture_primary_state(self, resource: Resource[Any]) -> dict[str, set[int]]:
|
|
314
|
+
"""Capture indices of elements with primary=True for each multi-valued attribute."""
|
|
315
|
+
state: dict[str, set[int]] = {}
|
|
316
|
+
for field_name in type(resource).model_fields:
|
|
317
|
+
if not resource.get_field_multiplicity(field_name):
|
|
318
|
+
continue
|
|
412
319
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
320
|
+
field_value = getattr(resource, field_name, None)
|
|
321
|
+
if not field_value:
|
|
322
|
+
continue
|
|
416
323
|
|
|
417
|
-
|
|
324
|
+
element_type = resource.get_field_root_type(field_name)
|
|
325
|
+
if (
|
|
326
|
+
not element_type
|
|
327
|
+
or not isclass(element_type)
|
|
328
|
+
or not issubclass(element_type, PydanticBaseModel)
|
|
329
|
+
or "primary" not in element_type.model_fields
|
|
330
|
+
):
|
|
331
|
+
continue
|
|
418
332
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
values: list[Any],
|
|
426
|
-
) -> bool:
|
|
427
|
-
"""Add multiple values to a multi-valued attribute."""
|
|
428
|
-
new_values = []
|
|
429
|
-
# RFC 7644 Section 3.5.2.1: "Do not add duplicate values"
|
|
430
|
-
for new_val in values:
|
|
431
|
-
if not cls._value_exists_in_list(current_list, new_val):
|
|
432
|
-
new_values.append(new_val)
|
|
433
|
-
|
|
434
|
-
if not new_values:
|
|
435
|
-
return False
|
|
333
|
+
primary_indices = {
|
|
334
|
+
i
|
|
335
|
+
for i, item in enumerate(field_value)
|
|
336
|
+
if getattr(item, "primary", None) is True
|
|
337
|
+
}
|
|
338
|
+
state[field_name] = primary_indices
|
|
436
339
|
|
|
437
|
-
|
|
438
|
-
return True
|
|
340
|
+
return state
|
|
439
341
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
"""Add a single value to a multi-valued attribute."""
|
|
445
|
-
# RFC 7644 Section 3.5.2.1: "Do not add duplicate values"
|
|
446
|
-
if cls._value_exists_in_list(current_list, value):
|
|
447
|
-
return False
|
|
448
|
-
|
|
449
|
-
current_list.append(value)
|
|
450
|
-
setattr(resource, field_name, current_list)
|
|
451
|
-
return True
|
|
342
|
+
def _normalize_primary_after_patch(
|
|
343
|
+
self, resource: Resource[Any], before_state: dict[str, set[int]]
|
|
344
|
+
) -> None:
|
|
345
|
+
"""Normalize primary attributes after a patch operation.
|
|
452
346
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
""
|
|
456
|
-
|
|
347
|
+
Per :rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`: a PATCH operation that
|
|
348
|
+
sets a value's "primary" sub-attribute to "true" SHALL cause the server
|
|
349
|
+
to automatically set "primary" to "false" for any other values.
|
|
350
|
+
"""
|
|
351
|
+
for field_name in type(resource).model_fields:
|
|
352
|
+
if not resource.get_field_multiplicity(field_name):
|
|
353
|
+
continue
|
|
457
354
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
parent_class = type(resource).get_field_root_type(parent_field_name)
|
|
462
|
-
if not parent_class or not isclass(parent_class):
|
|
463
|
-
return None
|
|
355
|
+
field_value = getattr(resource, field_name, None)
|
|
356
|
+
if not field_value:
|
|
357
|
+
continue
|
|
464
358
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
359
|
+
element_type = resource.get_field_root_type(field_name)
|
|
360
|
+
if (
|
|
361
|
+
not element_type
|
|
362
|
+
or not isclass(element_type)
|
|
363
|
+
or not issubclass(element_type, PydanticBaseModel)
|
|
364
|
+
or "primary" not in element_type.model_fields
|
|
365
|
+
):
|
|
366
|
+
continue
|
|
468
367
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
368
|
+
current_primary_indices = {
|
|
369
|
+
i
|
|
370
|
+
for i, item in enumerate(field_value)
|
|
371
|
+
if getattr(item, "primary", None) is True
|
|
372
|
+
}
|
|
473
373
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
raise ValueError(Error.make_invalid_path_error().detail)
|
|
374
|
+
if len(current_primary_indices) <= 1:
|
|
375
|
+
continue
|
|
477
376
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
if not field_name:
|
|
481
|
-
raise ValueError(Error.make_no_target_error().detail)
|
|
482
|
-
parent_obj = getattr(target, field_name)
|
|
377
|
+
before_primaries = before_state.get(field_name, set())
|
|
378
|
+
new_primaries = current_primary_indices - before_primaries
|
|
483
379
|
|
|
484
|
-
|
|
485
|
-
|
|
380
|
+
if len(new_primaries) > 1:
|
|
381
|
+
raise InvalidValueException(
|
|
382
|
+
detail=f"Multiple values marked as primary in field '{field_name}'"
|
|
383
|
+
)
|
|
486
384
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
385
|
+
if not new_primaries:
|
|
386
|
+
raise InvalidValueException(
|
|
387
|
+
detail=f"Multiple primary values already exist in field '{field_name}'"
|
|
388
|
+
)
|
|
491
389
|
|
|
492
|
-
|
|
493
|
-
|
|
390
|
+
keep_index = next(iter(new_primaries))
|
|
391
|
+
for i in current_primary_indices:
|
|
392
|
+
if i != keep_index:
|
|
393
|
+
field_value[i].primary = False
|
|
494
394
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
cls, resource: Resource[Any], path: str, value_to_remove: Any
|
|
395
|
+
def _apply_remove(
|
|
396
|
+
self, resource: Resource[Any], operation: PatchOperation[ResourceT]
|
|
498
397
|
) -> bool:
|
|
499
|
-
"""
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
# RFC 7644 Section 3.5.2.3: "Path must resolve to a valid attribute"
|
|
503
|
-
if not attr_path or not target:
|
|
504
|
-
raise ValueError(Error.make_invalid_path_error().detail)
|
|
505
|
-
|
|
506
|
-
field_name = _find_field_name(type(target), attr_path)
|
|
507
|
-
if not field_name:
|
|
508
|
-
raise ValueError(Error.make_no_target_error().detail)
|
|
509
|
-
|
|
510
|
-
current_list = getattr(target, field_name)
|
|
511
|
-
if not isinstance(current_list, list):
|
|
512
|
-
return False
|
|
513
|
-
|
|
514
|
-
new_list = []
|
|
515
|
-
modified = False
|
|
516
|
-
# RFC 7644 Section 3.5.2.3: "Remove matching values from multi-valued attributes"
|
|
517
|
-
for item in current_list:
|
|
518
|
-
if not cls._values_match(item, value_to_remove):
|
|
519
|
-
new_list.append(item)
|
|
520
|
-
else:
|
|
521
|
-
modified = True
|
|
522
|
-
|
|
523
|
-
if modified:
|
|
524
|
-
setattr(target, field_name, new_list if new_list else None)
|
|
525
|
-
return True
|
|
526
|
-
|
|
527
|
-
return False
|
|
528
|
-
|
|
529
|
-
@classmethod
|
|
530
|
-
def _values_match(cls, value1: Any, value2: Any) -> bool:
|
|
531
|
-
"""Check if two values match, converting BaseModel to dict for comparison."""
|
|
532
|
-
|
|
533
|
-
def to_dict(value: Any) -> dict[str, Any]:
|
|
534
|
-
return value.model_dump() if isinstance(value, BaseModel) else value
|
|
398
|
+
"""Apply a remove operation."""
|
|
399
|
+
if operation.path is None:
|
|
400
|
+
raise NoTargetException(detail="Remove operation requires a path")
|
|
535
401
|
|
|
536
|
-
return
|
|
402
|
+
return operation.path.delete(resource, operation.value) # type: ignore[arg-type]
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
from enum import Enum
|
|
2
|
-
from typing import
|
|
2
|
+
from typing import Any
|
|
3
3
|
|
|
4
4
|
from pydantic import field_validator
|
|
5
5
|
from pydantic import model_validator
|
|
6
6
|
|
|
7
|
-
from ..
|
|
8
|
-
from ..
|
|
9
|
-
from .error import Error
|
|
7
|
+
from ..path import URN
|
|
8
|
+
from ..path import Path
|
|
10
9
|
from .message import Message
|
|
11
10
|
|
|
12
11
|
|
|
@@ -16,71 +15,24 @@ class SearchRequest(Message):
|
|
|
16
15
|
https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.3
|
|
17
16
|
"""
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
"urn:ietf:params:scim:api:messages:2.0:SearchRequest"
|
|
21
|
-
]
|
|
18
|
+
__schema__ = URN("urn:ietf:params:scim:api:messages:2.0:SearchRequest")
|
|
22
19
|
|
|
23
|
-
attributes: list[
|
|
20
|
+
attributes: list[Path[Any]] | None = None
|
|
24
21
|
"""A multi-valued list of strings indicating the names of resource
|
|
25
22
|
attributes to return in the response, overriding the set of attributes that
|
|
26
23
|
would be returned by default."""
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
@classmethod
|
|
30
|
-
def validate_attributes_syntax(cls, v: list[str] | None) -> list[str] | None:
|
|
31
|
-
"""Validate syntax of attribute paths."""
|
|
32
|
-
if v is None:
|
|
33
|
-
return v
|
|
34
|
-
|
|
35
|
-
for attr in v:
|
|
36
|
-
if not _validate_scim_path_syntax(attr):
|
|
37
|
-
raise ValueError(Error.make_invalid_path_error().detail)
|
|
38
|
-
|
|
39
|
-
return v
|
|
40
|
-
|
|
41
|
-
excluded_attributes: list[str] | None = None
|
|
25
|
+
excluded_attributes: list[Path[Any]] | None = None
|
|
42
26
|
"""A multi-valued list of strings indicating the names of resource
|
|
43
27
|
attributes to be removed from the default set of attributes to return."""
|
|
44
28
|
|
|
45
|
-
@field_validator("excluded_attributes")
|
|
46
|
-
@classmethod
|
|
47
|
-
def validate_excluded_attributes_syntax(
|
|
48
|
-
cls, v: list[str] | None
|
|
49
|
-
) -> list[str] | None:
|
|
50
|
-
"""Validate syntax of excluded attribute paths."""
|
|
51
|
-
if v is None:
|
|
52
|
-
return v
|
|
53
|
-
|
|
54
|
-
for attr in v:
|
|
55
|
-
if not _validate_scim_path_syntax(attr):
|
|
56
|
-
raise ValueError(Error.make_invalid_path_error().detail)
|
|
57
|
-
|
|
58
|
-
return v
|
|
59
|
-
|
|
60
29
|
filter: str | None = None
|
|
61
30
|
"""The filter string used to request a subset of resources."""
|
|
62
31
|
|
|
63
|
-
sort_by:
|
|
32
|
+
sort_by: Path[Any] | None = None
|
|
64
33
|
"""A string indicating the attribute whose value SHALL be used to order the
|
|
65
34
|
returned responses."""
|
|
66
35
|
|
|
67
|
-
@field_validator("sort_by")
|
|
68
|
-
@classmethod
|
|
69
|
-
def validate_sort_by_syntax(cls, v: str | None) -> str | None:
|
|
70
|
-
"""Validate syntax of sort_by attribute path.
|
|
71
|
-
|
|
72
|
-
:param v: The sort_by attribute path to validate
|
|
73
|
-
:return: The validated sort_by attribute path
|
|
74
|
-
:raises ValueError: If sort_by attribute path has invalid syntax
|
|
75
|
-
"""
|
|
76
|
-
if v is None:
|
|
77
|
-
return v
|
|
78
|
-
|
|
79
|
-
if not _validate_scim_path_syntax(v):
|
|
80
|
-
raise ValueError(Error.make_invalid_path_error().detail)
|
|
81
|
-
|
|
82
|
-
return v
|
|
83
|
-
|
|
84
36
|
class SortOrder(str, Enum):
|
|
85
37
|
ascending = "ascending"
|
|
86
38
|
descending = "descending"
|
|
@@ -95,7 +47,7 @@ class SearchRequest(Message):
|
|
|
95
47
|
@field_validator("start_index")
|
|
96
48
|
@classmethod
|
|
97
49
|
def start_index_floor(cls, value: int | None) -> int | None:
|
|
98
|
-
"""According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4
|
|
50
|
+
"""According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>`, start_index values less than 1 are interpreted as 1.
|
|
99
51
|
|
|
100
52
|
A value less than 1 SHALL be interpreted as 1.
|
|
101
53
|
"""
|
|
@@ -108,7 +60,7 @@ class SearchRequest(Message):
|
|
|
108
60
|
@field_validator("count")
|
|
109
61
|
@classmethod
|
|
110
62
|
def count_floor(cls, value: int | None) -> int | None:
|
|
111
|
-
"""According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4
|
|
63
|
+
"""According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>`, count values less than 0 are interpreted as 0.
|
|
112
64
|
|
|
113
65
|
A negative value SHALL be interpreted as 0.
|
|
114
66
|
"""
|