Repository: oasis-open/cti-taxii-client

Issue:
ERROR - _populate_fields() got an unexpected keyword argument 'type'
```
server = Server(url, user=self.conf_api["user"], password=self.conf_api["password"], verify=False)
api_root = server.api_roots[0]
collections = api_root.collections
```
These code lines return this exceptions:

> ERROR - _populate_fields() got an unexpected keyword argument 'type'

When I use curl, server returns like this:

> {"collections": [{"can_read": true, "can_write": false, "description": "This data collection is for collecting malicious domains", "id": "861ecee6-09af-4cc9-8f80-8c6eac8bb5a4", "media_types": ["application/vnd.oasis.stix+json; version=2.0"], "title": "Indicator Domain Collection", "type": "domain"}]}

This error happens when we add a new field to collection `"type": "domain"`. But STIX standards allow us to custom collection fields.
Can you fix this? Thank you so much!


Discussion:

Patch:
diff --git a/taxii2client/__init__.py b/taxii2client/__init__.py
index 8d5c722..548f279 100644
--- a/taxii2client/__init__.py
+++ b/taxii2client/__init__.py
@@ -217,6 +217,10 @@ class Status(_TAXIIEndpoint):
         """Get the "raw" status response (parsed JSON)."""
         return self.__raw
 
+    @property
+    def custom_properties(self):
+        return self._custom_properties
+
     def refresh(self, accept=MEDIA_TYPE_TAXII_V20):
         """Updates Status information"""
         response = self.__raw = self._conn.get(self.url,
@@ -244,7 +248,8 @@ class Status(_TAXIIEndpoint):
     def _populate_fields(self, id=None, status=None, total_count=None,
                          success_count=None, failure_count=None,
                          pending_count=None, request_timestamp=None,
-                         successes=None, failures=None, pendings=None):
+                         successes=None, failures=None, pendings=None,
+                         **kwargs):
         self.id = id  # required
         self.status = status  # required
         self.request_timestamp = request_timestamp  # optional
@@ -256,6 +261,9 @@ class Status(_TAXIIEndpoint):
         self.failures = failures or []  # optional
         self.pendings = pendings or []  # optional
 
+        # Anything not captured by the optional arguments is treated as custom
+        self._custom_properties = kwargs
+
         self._validate_status()
 
     def _validate_status(self):
@@ -400,6 +408,11 @@ class Collection(_TAXIIEndpoint):
         self._ensure_loaded()
         return self._media_types
 
+    @property
+    def custom_properties(self):
+        self._ensure_loaded()
+        return self._custom_properties
+
     @property
     def objects_url(self):
         return self.url + "objects/"
@@ -411,7 +424,8 @@ class Collection(_TAXIIEndpoint):
         return self.__raw
 
     def _populate_fields(self, id=None, title=None, description=None,
-                         can_read=None, can_write=None, media_types=None):
+                         can_read=None, can_write=None, media_types=None,
+                         **kwargs):
         self._id = id  # required
         self._title = title  # required
         self._description = description  # optional
@@ -419,6 +433,9 @@ class Collection(_TAXIIEndpoint):
         self._can_write = can_write  # required
         self._media_types = media_types or []  # optional
 
+        # Anything not captured by the optional arguments is treated as custom
+        self._custom_properties = kwargs
+
         self._validate_collection()
 
     def _validate_collection(self):
@@ -628,6 +645,11 @@ class ApiRoot(_TAXIIEndpoint):
         self._ensure_loaded_information()
         return self._max_content_length
 
+    @property
+    def custom_properties(self):
+        self._ensure_loaded_information()
+        return self._custom_properties
+
     @property
     def _raw(self):
         """Get the "raw" API root information response (parsed JSON)."""
@@ -653,6 +675,18 @@ class ApiRoot(_TAXIIEndpoint):
             msg = "No 'max_content_length' in API Root for request '{}'"
             raise ValidationError(msg.format(self.url))
 
+    def _populate_fields(self, title=None, description=None, versions=None,
+                         max_content_length=None, **kwargs):
+        self._title = title  # required
+        self._description = description  # optional
+        self._versions = versions or []  # required
+        self._max_content_length = max_content_length  # required
+
+        # Anything not captured by the optional arguments is treated as custom
+        self._custom_properties = kwargs
+
+        self._validate_api_root()
+
     def refresh(self, accept=MEDIA_TYPE_TAXII_V20):
         """Update the API Root's information and list of Collections"""
         self.refresh_information(accept)
@@ -665,13 +699,7 @@ class ApiRoot(_TAXIIEndpoint):
         """
         response = self.__raw = self._conn.get(self.url,
                                                headers={"Accept": accept})
-
-        self._title = response.get("title")  # required
-        self._description = response.get("description")  # optional
-        self._versions = response.get("versions", [])  # required
-        self._max_content_length = response.get("max_content_length")  # required
-
-        self._validate_api_root()
+        self._populate_fields(**response)
         self._loaded_information = True
 
     def refresh_collections(self, accept=MEDIA_TYPE_TAXII_V20):
@@ -757,6 +785,11 @@ class Server(_TAXIIEndpoint):
         self._ensure_loaded()
         return self._api_roots
 
+    @property
+    def custom_properties(self):
+        self._ensure_loaded()
+        return self._custom_properties
+
     @property
     def _raw(self):
         """Get the "raw" server discovery response (parsed JSON)."""
@@ -774,14 +807,12 @@ class Server(_TAXIIEndpoint):
             msg = "No 'title' in Server Discovery for request '{}'"
             raise ValidationError(msg.format(self.url))
 
-    def refresh(self):
-        """Update the Server information and list of API Roots"""
-        response = self.__raw = self._conn.get(self.url)
-
-        self._title = response.get("title")  # required
-        self._description = response.get("description")  # optional
-        self._contact = response.get("contact")  # optional
-        roots = response.get("api_roots", [])  # optional
+    def _populate_fields(self, title=None, description=None, contact=None,
+                         api_roots=None, default=None, **kwargs):
+        self._title = title  # required
+        self._description = description  # optional
+        self._contact = contact  # optional
+        roots = api_roots or []  # optional
         self._api_roots = [ApiRoot(url,
                                    user=self._user,
                                    password=self._password,
@@ -791,9 +822,17 @@ class Server(_TAXIIEndpoint):
         # rather than creating a duplicate. The TAXII 2.0 spec says that the
         # `default` API Root MUST be an item in `api_roots`.
         root_dict = dict(zip(roots, self._api_roots))
-        self._default = root_dict.get(response.get("default"))  # optional
+        self._default = root_dict.get(default)  # optional
+
+        # Anything not captured by the optional arguments is treated as custom
+        self._custom_properties = kwargs
+
         self._validate_server()
 
+    def refresh(self):
+        """Update the Server information and list of API Roots"""
+        response = self.__raw = self._conn.get(self.url)
+        self._populate_fields(**response)
         self._loaded = True
 
 


Test patch:
diff --git a/taxii2client/test/test_client.py b/taxii2client/test/test_client.py
index 0bae132..e363d27 100644
--- a/taxii2client/test/test_client.py
+++ b/taxii2client/test/test_client.py
@@ -911,3 +911,52 @@ def test_header_merge_none():
     assert headers == {
         "user-agent": DEFAULT_USER_AGENT
     }
+
+
+def test_collection_with_custom_properties(collection_dict):
+    collection_dict["type"] = "domain"
+    col_obj = Collection(url=WRITABLE_COLLECTION_URL, collection_info=collection_dict)
+    assert len(col_obj.custom_properties) == 1
+    assert col_obj.custom_properties["type"] == "domain"
+
+
+def test_status_with_custom_properties(status_dict):
+    status_dict["x_example_com"] = "some value"
+    status_obj = Status(url=COLLECTION_URL, status_info=status_dict)
+    assert len(status_obj.custom_properties) == 1
+    assert status_obj.custom_properties["x_example_com"] == "some value"
+
+
+@responses.activate
+def test_api_roots_with_custom_properties(api_root):
+    response = """{
+      "title": "Malware Research Group",
+      "description": "A trust group setup for malware researchers",
+      "versions": ["taxii-2.0"],
+      "max_content_length": 9765625,
+      "x_example_com_total_items": 1000
+    }"""
+    set_api_root_response(response)
+    api_root.refresh_information()
+    assert len(api_root.custom_properties) == 1
+    assert api_root.custom_properties["x_example_com_total_items"] == 1000
+
+
+@responses.activate
+def test_server_with_custom_properties(server):
+    response = """{
+      "title": "Some TAXII Server",
+      "description": "This TAXII Server contains a listing of...",
+      "contact": "string containing contact information",
+      "default": "https://example.com/api2/",
+      "api_roots": [
+        "https://example.com/api1/",
+        "https://example.com/api2/",
+        "https://example.net/trustgroup1/"
+      ],
+      "x_example_com": "some value"
+    }"""
+    set_discovery_response(response)
+    server.refresh()
+    assert len(server.custom_properties) == 1
+    assert server.custom_properties["x_example_com"] == "some value"