diff --git a/searchlightclient/osc/v1/search.py b/searchlightclient/osc/v1/search.py new file mode 100644 index 0000000..6959a63 --- /dev/null +++ b/searchlightclient/osc/v1/search.py @@ -0,0 +1,97 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +"""Searchlight v1 Search action implementations""" + +import logging +import six + +from cliff import lister +from openstackclient.common import utils + + +class SearchResource(lister.Lister): + """Search Searchlight resource.""" + + log = logging.getLogger(__name__ + ".SearchResource") + + def get_parser(self, prog_name): + parser = super(SearchResource, self).get_parser(prog_name) + parser.add_argument( + "query", + metavar="", + help="Query resources by Elasticsearch query string " + "(NOTE: json format DSL is not supported yet). " + "Example: 'name: cirros AND updated_at: [now-1y TO now]'. " + "See Elasticsearch DSL or Searchlight documentation for " + "more detail." + ) + parser.add_argument( + "--type", + nargs='*', + metavar="", + help="One or more types to search. Uniquely identifies resource " + "types. Example: --type OS::Glance::Image " + "OS::Nova::Server" + ) + parser.add_argument( + "--all-projects", + action='store_true', + default=False, + help="By default searches are restricted to the current project " + "unless all_projects is set" + ) + parser.add_argument( + "--source", + action='store_true', + default=False, + help="Whether to display the source details, defaults to false. " + "You can specify --max-width to make the output look better." + ) + return parser + + @utils.log_method(log) + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + search_client = self.app.client_manager.search + mapping = {"_score": "score", "_type": "type", "_id": "id", + "_index": "index", "_source": "source"} + + params = { + "type": parsed_args.type, + "all_projects": parsed_args.all_projects + } + if parsed_args.source: + columns = ("ID", "Score", "Type", "Source") + else: + columns = ("ID", "Name", "Score", "Type", "Updated") + # Only return the required fields when source not specified. + params["_source"] = ["name", "updated_at"] + + # TODO(lyj): json should be supported for query + if parsed_args.query: + query = {"query_string": {"query": parsed_args.query}} + params['query'] = query + + data = search_client.search.search(**params) + result = [] + for r in data.hits['hits']: + converted = {} + for k, v in six.iteritems(r): + converted[mapping[k]] = v + if k == "_source" and not parsed_args.source: + converted["name"] = v.get("name") + converted["updated"] = v.get("updated_at") + result.append(utils.get_dict_properties(converted, columns)) + return (columns, result) diff --git a/searchlightclient/tests/unit/osc/v1/fakes.py b/searchlightclient/tests/unit/osc/v1/fakes.py index 1245424..c62149d 100644 --- a/searchlightclient/tests/unit/osc/v1/fakes.py +++ b/searchlightclient/tests/unit/osc/v1/fakes.py @@ -35,6 +35,23 @@ Facet = { } +Resource = { + "hits": + {"hits": + [ + {"_score": 0.3, "_type": "OS::Glance::Image", "_id": "1", + "_source": {"name": "image1", + "updated_at": "2016-01-01T00:00:00Z"}}, + {"_score": 0.3, "_type": "OS::Nova::Server", "_id": "2", + "_source": {"name": "instance1", + "updated_at": "2016-01-01T00:00:00Z"}}, + ], + "_shards": {"successful": 5, "failed": 0, "total": 5}, + "took": 5, "timed_out": False + } +} + + class FakeSearchv1Client(object): def __init__(self, **kwargs): self.http_client = mock.Mock() @@ -44,6 +61,8 @@ class FakeSearchv1Client(object): self.resource_types.list = mock.Mock(return_value=[]) self.facets = mock.Mock() self.facets.list = mock.Mock(return_value=[]) + self.search = mock.Mock() + self.search.search = mock.Mock(return_value=[]) class TestSearchv1(utils.TestCommand): diff --git a/searchlightclient/tests/unit/osc/v1/test_search.py b/searchlightclient/tests/unit/osc/v1/test_search.py new file mode 100644 index 0000000..4d60057 --- /dev/null +++ b/searchlightclient/tests/unit/osc/v1/test_search.py @@ -0,0 +1,91 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +import copy + +from searchlightclient.osc.v1 import search +from searchlightclient.tests.unit.osc import fakes +from searchlightclient.tests.unit.osc.v1 import fakes as searchlight_fakes + + +class TestSearch(searchlight_fakes.TestSearchv1): + def setUp(self): + super(TestSearch, self).setUp() + self.search_client = self.app.client_manager.search.search + + +class TestSearchResource(TestSearch): + + def setUp(self): + super(TestSearchResource, self).setUp() + self.cmd = search.SearchResource(self.app, None) + self.search_client.search.return_value = \ + fakes.FakeResource( + None, + copy.deepcopy(searchlight_fakes.Resource), + loaded=True) + + def _test_search(self, arglist, **assertArgs): + parsed_args = self.check_parser(self.cmd, arglist, []) + columns, data = self.cmd.take_action(parsed_args) + details = False + if assertArgs.get('source'): + details = assertArgs.pop('source') + self.search_client.search.assert_called_with(**assertArgs) + + if details: + collist = ("ID", "Score", "Type", "Source") + datalist = ( + ('1', 0.3, 'OS::Glance::Image', + {'name': 'image1', 'updated_at': '2016-01-01T00:00:00Z'}), + ('2', 0.3, 'OS::Nova::Server', + {'name': 'instance1', 'updated_at': '2016-01-01T00:00:00Z'})) + else: + collist = ("ID", "Name", "Score", "Type", "Updated") + datalist = (('1', 'image1', 0.3, 'OS::Glance::Image', + '2016-01-01T00:00:00Z'), + ('2', 'instance1', 0.3, 'OS::Nova::Server', + '2016-01-01T00:00:00Z')) + + self.assertEqual(collist, columns) + self.assertEqual(datalist, tuple(data)) + + def test_search(self): + self._test_search(["name: fake"], + query={"query_string": {"query": "name: fake"}}, + _source=['name', 'updated_at'], + all_projects=False, type=None) + + def test_search_resource(self): + self._test_search(["name: fake", "--type", "res1", "res2"], + query={"query_string": {"query": "name: fake"}}, + _source=['name', 'updated_at'], + type=["res1", "res2"], + all_projects=False) + + def test_search_query_only(self): + self._test_search(["name: fake"], + query={"query_string": {"query": "name: fake"}}, + _source=['name', 'updated_at'], + all_projects=False, type=None) + + def test_list_all_projects(self): + self._test_search(["name: fake", "--all-projects"], + query={"query_string": {"query": "name: fake"}}, + _source=['name', 'updated_at'], + all_projects=True, type=None) + + def test_list_source(self): + self._test_search(["name: fake", "--source"], + query={"query_string": {"query": "name: fake"}}, + all_projects=False, source=True, type=None) diff --git a/searchlightclient/tests/unit/v1/test_search.py b/searchlightclient/tests/unit/v1/test_search.py new file mode 100644 index 0000000..039d87f --- /dev/null +++ b/searchlightclient/tests/unit/v1/test_search.py @@ -0,0 +1,73 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from searchlightclient.v1 import search + +import mock +import testtools + + +class SearchManagerTest(testtools.TestCase): + + def setUp(self): + super(SearchManagerTest, self).setUp() + self.manager = search.SearchManager(None) + self.manager._post = mock.MagicMock() + + def test_search_with_query(self): + query_string = { + 'query_string': { + 'query': 'database' + } + } + self.manager.search(query=query_string) + self.manager._post.assert_called_once_with( + '/v1/search', {'query': query_string}) + + def test_search_with_type(self): + self.manager.search(type='fake_type') + self.manager._post.assert_called_once_with( + '/v1/search', {'type': 'fake_type'}) + + def test_search_with_offset(self): + self.manager.search(offset='fake_offset') + self.manager._post.assert_called_once_with( + '/v1/search', {'offset': 'fake_offset'}) + + def test_search_with_limit(self): + self.manager.search(limit=10) + self.manager._post.assert_called_once_with( + '/v1/search', {'limit': 10}) + + def test_search_with_sort(self): + self.manager.search(sort='asc') + self.manager._post.assert_called_once_with( + '/v1/search', {'sort': 'asc'}) + + def test_search_with_source(self): + self.manager.search(_source=['fake_source']) + self.manager._post.assert_called_once_with( + '/v1/search', {'_source': ['fake_source']}) + + def test_search_with_highlight(self): + self.manager.search(highlight='fake_highlight') + self.manager._post.assert_called_once_with( + '/v1/search', {'highlight': 'fake_highlight'}) + + def test_search_with_all_projects(self): + self.manager.search(all_projects=True) + self.manager._post.assert_called_once_with( + '/v1/search', {'all_projects': True}) + + def test_search_with_invalid_option(self): + self.manager.search(invalid='fake') + self.manager._post.assert_called_once_with('/v1/search', {}) diff --git a/searchlightclient/v1/client.py b/searchlightclient/v1/client.py index a098f38..4e01c08 100644 --- a/searchlightclient/v1/client.py +++ b/searchlightclient/v1/client.py @@ -13,6 +13,7 @@ from searchlightclient import client from searchlightclient.v1 import facets from searchlightclient.v1 import resource_types +from searchlightclient.v1 import search class Client(object): @@ -38,3 +39,4 @@ class Client(object): self.resource_types = resource_types.ResourceTypeManager( self.http_client) self.facets = facets.FacetsManager(self.http_client) + self.search = search.SearchManager(self.http_client) diff --git a/searchlightclient/v1/search.py b/searchlightclient/v1/search.py new file mode 100644 index 0000000..da2cf6e --- /dev/null +++ b/searchlightclient/v1/search.py @@ -0,0 +1,51 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import six + +from searchlightclient.openstack.common.apiclient import base + + +class Search(base.Resource): + def __repr__(self): + return "" % self._info + + def search(self, **kwargs): + return self.manager.search(self, **kwargs) + + +class SearchManager(base.BaseManager): + resource_class = Search + + def search(self, **kwargs): + """Executes a search query against searchlight and returns the 'hits' + from the response. Currently accepted parameters are (all optional): + + :param query: see Elasticsearch DSL or Searchlight documentation; + defaults to match everything + :param type: one or more types to search. Uniquely identifies resource + types. Example: OS::Glance::Image + :param offset: skip over this many results + :param limit: return this many results + :param sort: sort by one or more fields + :param _source: restrict the fields returned for each document + :param highlight: add an Elasticsearch highlight clause + :param all_projects: by default searches are restricted to the + current project unless all_projects is set + """ + search_params = {} + for k, v in six.iteritems(kwargs): + if k in ('query', 'type', 'offset', + 'limit', 'sort', '_source', 'highlight', 'all_projects'): + search_params[k] = v + resources = self._post('/v1/search', search_params) + return resources diff --git a/setup.cfg b/setup.cfg index 5cf228b..1c5f03b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,6 +29,7 @@ openstack.cli.extension = openstack.search.v1 = search_resource_type_list = searchlightclient.osc.v1.resource_type:ListResourceType search_facet_list = searchlightclient.osc.v1.facet:ListFacet + search_query = searchlightclient.osc.v1.search:SearchResource [global] setup-hooks =