Repository: python-scim/scim2-models

Issue:
`SearchRequest` `attributes` and `excludedAttributes` should be mutually exclusive
[RFC7644 §3.9](https://datatracker.ietf.org/doc/html/rfc7644#section-3.9) indicates that either `attributes` or `excludedAttributes` should be provided:

```
   Clients MAY request a partial resource representation on any
   operation that returns a resource within the response by specifying
   either of the mutually exclusive URL query parameters "attributes" or
   "excludedAttributes", as follows:
```

We should add a validator for this.

Discussion:

Solution patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1e2089c..8432396 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,13 +1,13 @@
 ---
 repos:
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: 'v0.6.4'
+    rev: 'v0.6.9'
     hooks:
       - id: ruff
         args: [--fix, --exit-non-zero-on-fix]
       - id: ruff-format
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.6.0
+    rev: v5.0.0
     hooks:
       - id: fix-byte-order-marker
       - id: trailing-whitespace
diff --git a/doc/changelog.rst b/doc/changelog.rst
index 5efe3f9..ae23594 100644
--- a/doc/changelog.rst
+++ b/doc/changelog.rst
@@ -1,6 +1,13 @@
 Changelog
 =========
 
+[0.2.3] - Unreleased
+--------------------
+
+Changed
+^^^^^^^
+- :attr:`SearchRequest.attributes <scim2_models.SearchRequest.attributes>` and :attr:`SearchRequest.attributes <scim2_models.SearchRequest.excluded_attributes>` are mutually exclusive. #19
+
 [0.2.2] - 2024-09-20
 --------------------
 
@@ -16,7 +23,6 @@ Fixed
 ^^^^^
 - :attr:`~scim2_models.Resource.external_id` is :data:`scim2_models.CaseExact.true`. #74
 
-
 [0.2.0] - 2024-08-18
 --------------------
 
diff --git a/scim2_models/rfc7644/search_request.py b/scim2_models/rfc7644/search_request.py
index d3b1cae..96fa8e2 100644
--- a/scim2_models/rfc7644/search_request.py
+++ b/scim2_models/rfc7644/search_request.py
@@ -3,6 +3,7 @@
 from typing import Optional
 
 from pydantic import field_validator
+from pydantic import model_validator
 
 from .message import Message
 
@@ -64,3 +65,12 @@ def count_floor(cls, value: int) -> int:
         """
 
         return None if value is None else max(1, value)
+
+    @model_validator(mode="after")
+    def attributes_validator(self):
+        if self.attributes and self.excluded_attributes:
+            raise ValueError(
+                "'attributes' and 'excluded_attributes' are mutually exclusive"
+            )
+
+        return self


Test patch:
diff --git a/tests/test_search_request.py b/tests/test_search_request.py
index 022306e..d4bdeb8 100644
--- a/tests/test_search_request.py
+++ b/tests/test_search_request.py
@@ -1,9 +1,20 @@
+import pytest
+from pydantic import ValidationError
+
 from scim2_models.rfc7644.search_request import SearchRequest
 
 
 def test_search_request():
     SearchRequest(
         attributes=["userName", "displayName"],
+        filter='userName Eq "john"',
+        sort_by="userName",
+        sort_order=SearchRequest.SortOrder.ascending,
+        start_index=1,
+        count=10,
+    )
+
+    SearchRequest(
         excluded_attributes=["timezone", "phoneNumbers"],
         filter='userName Eq "john"',
         sort_by="userName",
@@ -42,3 +53,24 @@ def test_count_floor():
 
     sr = SearchRequest(count=-1)
     assert sr.count == 1
+
+
+def test_attributes_or_excluded_attributes():
+    """Test that a validation error is raised when both 'attributes' and 'excludedAttributes' are filled at the same time.
+    https://datatracker.ietf.org/doc/html/rfc7644#section-3.9
+
+        Clients MAY request a partial resource representation on any
+        operation that returns a resource within the response by specifying
+        either of the mutually exclusive URL query parameters "attributes" or
+        "excludedAttributes"...
+    """
+
+    payload = {
+        "schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"],
+        "attributes": ["userName"],
+        "excludedAttributes": [
+            "displayName",
+        ],
+    }
+    with pytest.raises(ValidationError):
+        SearchRequest.model_validate(payload)