diff --git a/app/api/network.py b/app/api/network.py index 99c3640de05efc88c707fcd32d45ec6f4b96397c..585bb82d6912f5bfcb543a6fc3fb02d75fbb2f0f 100644 --- a/app/api/network.py +++ b/app/api/network.py @@ -203,6 +203,18 @@ def get_hosts(): return utils.get_generic_model(models.Host, order_by=models.Host.name) +@bp.route("/hosts/search") +@login_required +def search_hosts(): + """Search hosts + + .. :quickref: Network; Search hosts + + :query q: the search query + """ + return utils.search_generic_model(models.Host) + + @bp.route("/hosts", methods=["POST"]) @login_groups_accepted("admin", "network") def create_host(): diff --git a/app/api/utils.py b/app/api/utils.py index db8c004c1ddac959796ea3d4dd3946be486021cc..b9e69644f3a1fa06d7a063868e9b2a20de14f376 100644 --- a/app/api/utils.py +++ b/app/api/utils.py @@ -12,6 +12,7 @@ This module implements useful functions for the API. import urllib.parse import sqlalchemy as sa from flask import current_app, jsonify, request +from flask_sqlalchemy import Pagination from ..extensions import db from .. import utils @@ -86,6 +87,26 @@ def get_generic_model(model, order_by=None, query=None): return jsonify(data), 200, header +def search_generic_model(model): + """Return filtered data from model as json + + :param model: model class + :returns: filtered data from model as json + """ + kwargs = request.args.to_dict() + page = int(kwargs.pop("page", 1)) + per_page = int(kwargs.pop("per_page", 20)) + search = kwargs.get("q", "*") + instances, nb_filtered = model.search(search, page=page, per_page=per_page) + current_app.logger.debug( + f'Found {nb_filtered} {model.__tablename__}(s) when searching "{search}"' + ) + data = [instance.to_dict(recursive=True) for instance in instances] + pagination = Pagination(None, page, per_page, nb_filtered, None) + header = build_pagination_header(pagination, request.base_url, **kwargs) + return jsonify(data), 200, header + + def create_generic_model(model, mandatory_fields=("name",), **kwargs): data = request.get_json() if data is None: diff --git a/docs/api.rst b/docs/api.rst index a489d506184c2945233c1d8e46f81e102f1f6734..0b0a7bc88a5b475e84b09a170dd4b97579b5f5c0 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -137,6 +137,77 @@ If you want to retrieve all items with the status "Loaned". ... ] + +Searching +--------- + +To search hosts, you can use a :http:get:`/api/v1/network/hosts/search` with your query +passed using the ``q`` query parameter. + +- To search all hosts where the string "archiver" appears in any field, use ``q=archiver``:: + + curl -v -H "Authorization: Bearer $TOKEN" $URL/api/v1/network/hosts/search?q=archiver + + Example response:: + + < HTTP/1.0 200 OK + < Content-Type: application/json + < Content-Length: 6336 + < X-Total-Count: 5 + < Set-Cookie: session=9afbc88a-5177-4ece-bc55-7840a47290a1; Expires=Mon, 29-Apr-2019 15:27:37 GMT; HttpOnly; Path=/ + < Server: Werkzeug/0.14.1 Python/3.7.1 + < Date: Fri, 29 Mar 2019 15:27:37 GMT + < + [ + { + "ansible_groups": [ + "aa_cluster_test", + "archiver_appliance" + ], + "created_at": "2018-11-19 10:08", + "description": "test archiver", + "device_type": "VirtualMachine", + "fqdn": "archiver-04.tn.esss.lu.se", + "id": 401, + ... + "name": "archiver-04", + "updated_at": "2018-11-20 07:43", + "user": "benjaminbertrand" + }, + { + ... + "name": "archiver-01", + "updated_at": "2018-11-28 09:29", + "user": "benjaminbertrand" + ... + }, + ... + ] + + +- To restrict the search to only the *name* field, use ``q=name:archiver``:: + + curl -v -H "Authorization: Bearer $TOKEN" $URL/api/v1/network/hosts/search?q=name:archiver + + < HTTP/1.0 200 OK + < Content-Type: application/json + < Content-Length: 5374 + < X-Total-Count: 4 + < Set-Cookie: session=1a637fca-c94b-4ab9-b290-10d18f35453e; Expires=Mon, 29-Apr-2019 15:37:37 GMT; HttpOnly; Path=/ + < Server: Werkzeug/0.14.1 Python/3.7.1 + < Date: Fri, 29 Mar 2019 15:37:37 GMT + < + ... + + +The query string is passed directly to Elasticsearch. You can use exactly the same "mini-language" as when searching +from the web user interface. Check the elasticsearch `query string syntax`_ for more details as well as the network +:ref:`network-search` for the list of fields you can use. + +Note that if you pass ``q=name:arch``, this will not match *archiver* as elasticsearch uses keywords for string matching. +In this case you'd need to use a wildcard: ``q=name:arch*``. Be aware that wildcard queries can use an enormous amount +of memory and perform very badly. + Resources --------- @@ -175,3 +246,5 @@ Client A Python client library is available to access CSEntry API: `csentry-api <http://ics-infrastructure.pages.esss.lu.se/csentry-api/index.html>`_ + +.. _query string syntax: https://www.elastic.co/guide/en/elasticsearch/reference/6.4/query-dsl-query-string-query.html#query-string-syntax diff --git a/docs/network.rst b/docs/network.rst index 8fde5890b5c4f7c125d2c5ef1cbba2c13f2f9c08..c317f46168494178625f06a9e5c71bb6ec863a71 100644 --- a/docs/network.rst +++ b/docs/network.rst @@ -28,6 +28,8 @@ Stack members should be defined by linking an item to an host from the inventory .. image:: _static/edit_item_stack_member.png +.. _network-search: + Search ------ diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py index a6dfe5fab995f2af7af5c06a6a8ac437608c9ad1..23ca0ed300b9c4cdaa75375e0a41422f67483291 100644 --- a/tests/functional/test_api.py +++ b/tests/functional/test_api.py @@ -1673,3 +1673,30 @@ def test_create_cname(client, interface, admin_token): check_response_message( response, f"Duplicate cname on the {interface.network.domain} domain", 422 ) + + +def test_search_hosts(client, host_factory, readonly_token): + # Create some hosts + host1 = host_factory(name="test-beautiful", description="The Zen of Python") + host_factory(name="test-explicit", description="Beautiful is better than ugly.") + host_factory(name="another-host") + # When no query is passed, all hosts are returned + response = get(client, f"{API_URL}/network/hosts/search", token=readonly_token) + assert response.status_code == 200 + assert len(response.get_json()) == 3 + # a keyword is searched in all fields by default + response = get( + client, f"{API_URL}/network/hosts/search?q=beautiful", token=readonly_token + ) + assert response.status_code == 200 + assert len(response.get_json()) == 2 + # a search can be restricted to a specific field + response = get( + client, f"{API_URL}/network/hosts/search?q=name:beautiful", token=readonly_token + ) + assert response.status_code == 200 + r = response.get_json() + assert len(r) == 1 + assert HOST_KEYS == set(r[0].keys()) + assert r[0]["name"] == host1.name + assert r[0]["description"] == host1.description