From da42a2f8d9f36e26efb51e9e7de37db8880dd8ba Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand <benjamin.bertrand@esss.se> Date: Thu, 28 Mar 2019 21:58:14 +0100 Subject: [PATCH] Add API endpoint to search hosts JIRA INFRA-931 #action In Progress --- app/api/network.py | 12 ++++++ app/api/utils.py | 21 +++++++++++ docs/api.rst | 73 ++++++++++++++++++++++++++++++++++++ docs/network.rst | 2 + tests/functional/test_api.py | 27 +++++++++++++ 5 files changed, 135 insertions(+) diff --git a/app/api/network.py b/app/api/network.py index 99c3640..585bb82 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 db8c004..b9e6964 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 a489d50..0b0a7bc 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 8fde589..c317f46 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 a6dfe5f..23ca0ed 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 -- GitLab