Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GraqhQL Inventory: Add ability for users to specify non-foreign key relationships with Null/None #123

Merged
merged 8 commits into from
Mar 30, 2022
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Dockerfile
docker-compose.yml
*.md
.env
.vscode/
.github/
2 changes: 1 addition & 1 deletion ansible.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
force_valid_group_names = always

[inventory]
enable_plugins = networktocode.nautobot.inventory, auto, host_list, yaml, ini, toml, script
enable_plugins = networktocode.nautobot.inventory, networktocode.nautobot.gql_inventory, yaml, ini
83 changes: 83 additions & 0 deletions plugins/filter/graphql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""GraphQL related filter plugins."""
# Copyright (c) 2022, Network to Code (@networktocode) <info@networktocode.com>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import absolute_import, division, print_function

__metaclass__ = type


def build_graphql_filter_string(filter: dict) -> str:
"""Takes a dictionary and builds a graphql filter

Args:
filter (dict): Key/Value pairs to build filter from

Returns:
str: Proper graphQL filter
"""
base_filter = "({0})"
loop_filters = []
for key, value in filter.items():
temp_string = f"{key}: "
value_string = f"{value}"

# GraphQL variables do not need quotes (This isn't completely support with inventory yet, but code added here)
if isinstance(value, str) and not key.startswith("$"):
value_string = "'" + value_string + "'"

loop_filters.append(temp_string + value_string)

return base_filter.format(", ".join(loop_filters))


def convert_to_graphql_string(query: dict, start=0) -> str:
"""Provide a dictionary to convert to a graphQL string.

Args:
query (dict): A dictionary mapping to the graphQL call to be made.
FragmentedPacket marked this conversation as resolved.
Show resolved Hide resolved
start (int): The starting indentation when compiling string.

Returns:
str: GraphQL query string
"""
graphql_string = """"""
loop_index = 0
query_len = len(query)
for k, v in query.items():
loop_index += 1
loop_string = "".rjust(start * 2, " ")
loop_filter = None

# Determine what to do
if not v:
loop_string += f"{k}"
elif isinstance(v, dict):
if v.get("filters"):
loop_filter = build_graphql_filter_string(v.pop("filters"))
loop_string += "{0} {1}\n".format(k, loop_filter + " {" if loop_filter else "{")
loop_string += convert_to_graphql_string(v, start + 1)
loop_string += "{0}{1}".format(" " * (start * 2), "}")
else:
loop_string += "{0} {1}\n".format(k, "{")
# We want to keep the double spaces, but add 2 more
loop_string += "{0}".format(" " * (start * 2 + 2))
loop_string += f"{v}\n"
loop_string += "{0}{1}".format(" " * (start * 2), "}")

if loop_index != query_len or start > 0:
loop_string += "\n"

graphql_string += loop_string

return graphql_string.replace("'", '"')


class FilterModule:
"""Return graphQL filters."""

def filters(self):
"""Map filter functions to filter names."""
return {
"graphql_string": convert_to_graphql_string,
}
38 changes: 31 additions & 7 deletions plugins/inventory/gql_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,22 @@
# inventory.yml file in YAML format
# Example command line: ansible-inventory -v --list -i inventory.yml

# Add additional query parameter with query key
# Add additional query parameter with query key and use filters
plugin: networktocode.nautobot.gql_inventory
api_endpoint: http://localhost:8000
validate_certs: True
query:
tags: name
serial:
site:
filters:
tenant: "den"
name:
description:
contact_name:
description:
region:
name:

# To group by use group_by key
# Specify the full path to the data you would like to use to group by.
Expand Down Expand Up @@ -134,7 +144,8 @@
from ansible.module_utils.urls import open_url

from ansible.module_utils.six.moves.urllib import error as urllib_error
from jinja2 import Environment, FileSystemLoader

from ansible_collections.networktocode.nautobot.plugins.filter.graphql import convert_to_graphql_string

try:
from netutils.lib_mapper import ANSIBLE_LIB_MAPPER_REVERSE, NAPALM_LIB_MAPPER
Expand Down Expand Up @@ -242,11 +253,23 @@ def main(self):
if not HAS_NETUTILS:
raise AnsibleError("networktocode.nautobot.gql_inventory requires netutils. Please pip install netutils.")

file_loader = FileSystemLoader(f"{PATH}/../templates")
env = Environment(loader=file_loader, autoescape=True)
template = env.get_template("graphql_default_query.j2")
query = template.render(query=self.gql_query, filters=self.filters)
data = {"query": "query {%s}" % query}
base_query = {
"query": {
"devices": {
"name": None,
"platform": "napalm_driver",
"status": "name",
"primary_ip4": "host",
"device_role": "name",
"site": "name",
}
}
}
base_query["query"]["devices"].update(self.gql_query)
if self.filters:
base_query["query"]["devices"]["filters"] = self.filters
query = convert_to_graphql_string(base_query)
data = {"query": query}

try:
response = open_url(
Expand All @@ -272,6 +295,7 @@ def main(self):
# Need to return mock response data that is empty to prevent any failures downstream
return {"results": [], "next": None}
else:
self.display.display(f"{e.code}", color="red")
self.display.display(
"Something went wrong while executing the query.\nReason: {reason}".format(
reason=json.loads(e.fp.read().decode())["errors"][0]["message"],
Expand Down
5 changes: 0 additions & 5 deletions plugins/templates/graphql_additional_query.j2

This file was deleted.

19 changes: 0 additions & 19 deletions plugins/templates/graphql_default_query.j2

This file was deleted.

1 change: 0 additions & 1 deletion plugins/templates/graphql_filters.j2

This file was deleted.

Empty file added tests/unit/filter/__init__.py
Empty file.
76 changes: 76 additions & 0 deletions tests/unit/filter/test_data/graphql_string.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
[
{
"query": {
"serial": null,
"asset_tag": null
},
"expected": "serial\nasset_tag"
},
{
"query": {
"query": {
"devices": {
"serial": null,
"asset_tag": null
}
}
},
"expected": "query {\n devices {\n serial\n asset_tag\n }\n}"
},
{
"query": {
"serial": null,
"asset_tag": null,
"site": {
"name": null,
"contact_name": null,
"description": null,
"region": {
"name": null,
"parent": {
"name": null
}
}
}
},
"expected": "serial\nasset_tag\nsite {\n name\n contact_name\n description\n region {\n name\n parent {\n name\n }\n }\n}"
},
{
"query": {
"serial": null,
"asset_tag": null,
"site": {
"filters": {
"role": "core",
"tenant": "den"
},
"name": null,
"contact_name": null,
"description": null,
"region": {
"filters": {
"$tenant": "den"
},
"name": null,
"parent": {
"name": null
}
}
}
},
"expected": "serial\nasset_tag\nsite (role: \"core\", tenant: \"den\") {\n name\n contact_name\n description\n region ($tenant: den) {\n name\n parent {\n name\n }\n }\n}"
},
{
"query": {
"device": {
"name": null,
"platform": "napalm_driver",
"status": "name",
"primary_ip4": "address",
"device_role": "name",
"site": "name"
}
},
"expected": "device {\n name\n platform {\n napalm_driver\n }\n status {\n name\n }\n primary_ip4 {\n address\n }\n device_role {\n name\n }\n site {\n name\n }\n}"
}
]
25 changes: 25 additions & 0 deletions tests/unit/filter/test_graphql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Tests for Nautobot GraphQL filter plugins."""

import json
import pytest
from ansible_collections.networktocode.nautobot.plugins.filter.graphql import (
convert_to_graphql_string,
build_graphql_filter_string,
)


def load_test_data(test):
with open(f"tests/unit/filter/test_data/{test}.json", "r") as f:
data = json.loads(f.read())
return data


@pytest.mark.parametrize(("test_data"), load_test_data("graphql_string"))
def test_convert_to_graphql_string(test_data):
result = convert_to_graphql_string(test_data["query"])
assert result == test_data["expected"]


def test_build_graphql_filter_string():
gql_filters = {"role": "core", "$tenant": "den", "device_type": "c9300"}
assert build_graphql_filter_string(gql_filters) == "(role: 'core', $tenant: den, device_type: 'c9300')"