Repository: box/box-python-sdk (python)

Issue:
Add support for searching by metadata value
The Search API now has the ability to search by metadata value using the "mdfilters" param.  The SDK should have support for this new functionality.

Pull request:
Add support for searching by metadata value (Fixes #36)
Added mdfilters param to search object

Patch:
diff --git a/boxsdk/client.py b/boxsdk/client.py
index 70518df..8ea4fd2 100644
--- a/boxsdk/client.py
+++ b/boxsdk/client.py
@@ -105,7 +105,7 @@ def users(self):
         response = box_response.json()
         return [User(self._session, item['id'], item) for item in response['entries']]
 
-    def search(self, query, limit, offset, ancestor_folders=None, file_extensions=None):
+    def search(self, query, limit, offset, ancestor_folders=None, file_extensions=None, metadata_filters=None):
         """
         Search Box for items matching the given query.
 
@@ -124,17 +124,26 @@ def search(self, query, limit, offset, ancestor_folders=None, file_extensions=No
         :param ancestor_folders:
             Folder ids to limit the search to.
         :type ancestor_folders:
-            `list` of `unicode`
+            `iterable` of :class:`Folder`
         :param file_extensions:
             File extensions to limit the search to.
         :type file_extensions:
-            `list` of `unicode`
+            `iterable` of `unicode`
+        :param metadata_filters:
+            Filters used for metadata search
+        :type metadata_filters:
+            :class:`MetadataSearchFilters`
         :return:
             A list of items that match the search query.
         :rtype:
             `list` of :class:`Item`
         """
-        return Search(self._session).search(query, limit, offset, ancestor_folders, file_extensions)
+        return Search(self._session).search(query=query,
+                                            limit=limit,
+                                            offset=offset,
+                                            ancestor_folders=ancestor_folders,
+                                            file_extensions=file_extensions,
+                                            metadata_filters=metadata_filters)
 
     def events(self):
         """
diff --git a/boxsdk/object/search.py b/boxsdk/object/search.py
index 382ea8b..3366d29 100644
--- a/boxsdk/object/search.py
+++ b/boxsdk/object/search.py
@@ -2,15 +2,160 @@
 
 from __future__ import unicode_literals
 
-from boxsdk.config import API
 from .base_endpoint import BaseEndpoint
 from boxsdk.util.translator import Translator
+import json
+
+
+class MetadataSearchFilter(object):
+    """
+    Helper class to encapsulate a single search filter. A search filter can only search against one template,
+    but can filter on many fields.
+    See :class:`MetadataSearchFilters`.
+    """
+    def __init__(self, template_key, scope):
+        """
+        :param template_key:
+            The key of the template to search on
+        :type template_key:
+            `unicode`
+        :param scope:
+            The scope of the template to search on
+        :type scope:
+            `unicode`
+        """
+        self._template_key = template_key
+        self._scope = scope
+        self._field_filters = {}
+
+    def as_dict(self):
+        """
+        Returns a `dict` representation of this object
+
+        :return:
+            The `dict` representation
+        :rtype:
+            `dict`
+        """
+        return {
+            'templateKey': self._template_key,
+            'scope': self._scope,
+            'filters': self._field_filters
+        }
+
+    def add_value_based_filter(self, field_key, value):
+        """
+        Add a value-based filter (used for token-based search on string fields, and exact match search on all other fields)
+
+        :param field_key:
+            The field key to filter on
+        :type field_filter:
+            `unicode`
+        :param value:
+            The value to use to filter
+        :type value:
+            `unicode`
+        """
+        self._field_filters.update({field_key: value})
+
+    def add_range_filter(self, field_key, gt_value=None, lt_value=None):
+        """
+        Add a range filter (used for ranged searches on numbers and dates)
+
+        :param field_key:
+            The field key to filter on
+        :type field_filter:
+            `unicode`
+        :param gt_value:
+            The lower bound of the range filter (inclusive)
+        :type gt_value:
+            `unicode` or `int` or `float` or `long` or None
+        :param lt_value:
+            The upper bound of the range filter (inclusive)
+        :type lt_value:
+            `unicode` or `int` or `float` or `long` or None
+        """
+        range_part = {}
+        if gt_value:
+            range_part['gt'] = gt_value
+        if lt_value:
+            range_part['lt'] = lt_value
+        if not range_part:
+            raise ValueError('Should specify gt and/or lt')
+        self._field_filters.update({field_key: range_part})
+
+
+class MetadataSearchFilters(object):
+    """
+    Helper class to encapsulate a list of metadata search filter params (mdfilters API param)
+    See https://developers.box.com/metadata-api/#search for more details
+    """
+    def __init__(self):
+        self._filters = []
+
+    def as_list(self):
+        """
+        Get a list of filters from this object to use as a parameter in the Search API
+
+        :return:
+            The list of filters
+        :rtype:
+            `list` of `dict`
+        """
+        return [metadata_filter.as_dict() for metadata_filter in self._filters]
+
+    def add_filter(self, metadata_filter):
+        """
+        Add a filter to this object. Note that the API only supports one filter.
+
+        :param metadata_filter:
+            The filter to add
+        :type metadata_filter:
+            :class:`MetadataSearchFilter`
+        """
+        self._filters.append(metadata_filter)
 
 
 class Search(BaseEndpoint):
     """Search Box for files and folders."""
 
-    def search(self, query, limit, offset, ancestor_folders=None, file_extensions=None):
+    def get_url(self, *args):
+        """
+        Gets the search endpoint URL.
+
+        :return:
+            The search endpoint URL.
+        :rtype:
+            `unicode`
+        """
+        return super(Search, self).get_url('search')
+
+    @staticmethod
+    def start_metadata_filters():
+        """
+        Get a :class:`MetadataSearchFilters` that represents a set of metadata filters.
+
+        :return:
+            The new :class:`MetadataSearchFilters`
+        :rtype:
+            :class:`MetadataSearchFilters`
+        """
+        return MetadataSearchFilters()
+
+    @staticmethod
+    def make_single_metadata_filter(template_key, scope):
+        """
+        Make a single :class:`MetadataSearchFilter` that represents a filter on a template. It must be
+        added to a :class:`MetadataSearchFilters`.
+
+        :return:
+            The new :class:`MetadataSearchFilter`
+        :rtype:
+            :class:`MetadataSearchFilter`
+        """
+        return MetadataSearchFilter(template_key, scope)
+
+    def search(self, query, limit, offset, ancestor_folders=None, file_extensions=None, metadata_filters=None):
         """
         Search Box for items matching the given query.
 
@@ -29,17 +174,21 @@ def search(self, query, limit, offset, ancestor_folders=None, file_extensions=No
         :param ancestor_folders:
             Folder ids to limit the search to.
         :type ancestor_folders:
-            `list` of `unicode`
+            `iterable` of :class:`Folder`
         :param file_extensions:
             File extensions to limit the search to.
         :type file_extensions:
-            `list` of `unicode`
+            `iterable` of `unicode`
+        :param metadata_filters:
+            Filters used for metadata search
+        :type metadata_filters:
+            :class:`MetadataSearchFilters`
         :return:
             A list of items that match the search query.
         :rtype:
             `list` of :class:`Item`
         """
-        url = '{0}/search'.format(API.BASE_API_URL)
+        url = self.get_url()
         params = {
             'query': query,
             'limit': limit,
@@ -53,6 +202,10 @@ def search(self, query, limit, offset, ancestor_folders=None, file_extensions=No
             params.update({
                 'file_extensions': ','.join(file_extensions)
             })
+        if metadata_filters:
+            params.update({
+                'mdfilters': json.dumps(metadata_filters.as_list())
+            })
         box_response = self._session.get(url, params=params)
         response = box_response.json()
         return [Translator().translate(item['type'])(self._session, item['id'], item) for item in response['entries']]


Test patch:
diff --git a/test/unit/object/conftest.py b/test/unit/object/conftest.py
index 638f667..6a2ae1e 100644
--- a/test/unit/object/conftest.py
+++ b/test/unit/object/conftest.py
@@ -10,6 +10,7 @@
 from boxsdk.object.folder import Folder
 from boxsdk.object.group import Group
 from boxsdk.object.user import User
+from boxsdk.object.search import Search
 
 
 @pytest.fixture(scope='module')
@@ -69,6 +70,12 @@ def test_group(mock_box_session, mock_group_id):
     return Group(mock_box_session, mock_group_id)
 
 
+@pytest.fixture()
+def test_search(mock_box_session):
+    # pylint:disable=redefined-outer-name
+    return Search(mock_box_session)
+
+
 @pytest.fixture(scope='function')
 def mock_collab_response(make_mock_box_request, mock_collaboration_id):
     # pylint:disable=redefined-outer-name
diff --git a/test/unit/object/test_search.py b/test/unit/object/test_search.py
index e17c22b..9b68738 100644
--- a/test/unit/object/test_search.py
+++ b/test/unit/object/test_search.py
@@ -1,3 +1,147 @@
 # coding: utf-8
 
 from __future__ import unicode_literals
+import pytest
+import json
+from boxsdk.object.file import File
+from boxsdk.object.search import MetadataSearchFilters, MetadataSearchFilter
+
+
+@pytest.fixture
+def search_query():
+    return 'myquery'
+
+
+@pytest.fixture
+def search_limit():
+    return 20
+
+
+@pytest.fixture
+def search_offset():
+    return 0
+
+
+@pytest.fixture
+def search_value_based_filters():
+    metadata_filters = MetadataSearchFilters()
+    metadata_filter = MetadataSearchFilter(template_key='mytemplate', scope='enterprise')
+    metadata_filter.add_value_based_filter(field_key='myfield', value='myvalue')
+    metadata_filters.add_filter(metadata_filter)
+    return metadata_filters
+
+
+@pytest.fixture(params=(
+    {'gt_value': 'mygtvalue'},
+    {'lt_value': 'myltvalue'},
+    {'gt_value': 'mygtvalue', 'lt_value': 'myltvalue'}
+))
+def search_range_filters(request):
+    metadata_filters = MetadataSearchFilters()
+    metadata_filter = MetadataSearchFilter(template_key='mytemplate', scope='enterprise')
+    filter_params = {'field_key': 'myfield'}
+    filter_params.update(request.param)
+    metadata_filter.add_range_filter(**filter_params)
+    metadata_filters.add_filter(metadata_filter)
+    return metadata_filters
+
+
+@pytest.fixture
+def search_entries():
+    return [
+        {'id': '1234', 'type': 'file'}
+    ]
+
+
+@pytest.fixture
+def search_response():
+    return {
+        'entries': search_entries(),
+        'total_count': 0,
+        'limit': 20,
+        'offset': 0
+    }
+
+
+class Matcher(object):
+    def __init__(self, compare, some_obj):
+        self.compare = compare
+        self.some_obj = some_obj
+
+    def __eq__(self, other):
+        return self.compare(self.some_obj, other)
+
+
+def compare_params(self, other):
+    if not isinstance(self, dict) or not isinstance(other, dict):
+        return False
+    for key in self:
+        # We need to ensure that the JSON-encoded mdfilters matches regardless of key order
+        if key == 'mdfilters':
+            if json.loads(self['mdfilters']) != json.loads(other['mdfilters']):
+                return False
+        # For other keys, just ensure that they are equal
+        else:
+            if self[key] != other[key]:
+                return False
+    return True
+
+
+def test_search_with_value_based_filters(
+        mock_box_session,
+        make_mock_box_request,
+        test_search,
+        search_query,
+        search_limit,
+        search_offset,
+        search_value_based_filters,
+        search_response,
+        search_entries
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_session.get.return_value, _ = make_mock_box_request(response=search_response)
+    response = test_search.search(search_query, limit=search_limit, offset=search_offset, metadata_filters=search_value_based_filters)
+    assert response == [File(mock_box_session, search_entry['id'], search_entry) for search_entry in search_entries]
+
+    mock_box_session.get.assert_called_once_with(
+        test_search.get_url(),
+        params=Matcher(compare_params, {
+            'query': 'myquery',
+            'limit': 20,
+            'mdfilters': json.dumps(search_value_based_filters.as_list()),
+            'offset': 0
+        })
+    )
+
+
+def test_search_with_range_filters(
+        mock_box_session,
+        make_mock_box_request,
+        test_search,
+        search_query,
+        search_limit,
+        search_offset,
+        search_range_filters,
+        search_response,
+        search_entries
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_session.get.return_value, _ = make_mock_box_request(response=search_response)
+    response = test_search.search(search_query, limit=search_limit, offset=search_offset, metadata_filters=search_range_filters)
+    assert response == [File(mock_box_session, search_entry['id'], search_entry) for search_entry in search_entries]
+
+    mock_box_session.get.assert_called_once_with(
+        test_search.get_url(),
+        params=Matcher(compare_params, {
+            'query': 'myquery',
+            'limit': 20,
+            'mdfilters': json.dumps(search_range_filters.as_list()),
+            'offset': 0
+        })
+    )
+
+
+def test_range_filter_without_gt_and_lt_will_fail_validation():
+    metadata_filter = MetadataSearchFilter(template_key='mytemplate', scope='enterprise')
+    with pytest.raises(ValueError):
+        metadata_filter.add_range_filter(field_key='mykey')