Skip to content

Commit

Permalink
Merge branch 'develop' into #32-add-run-job-and-wait-method
Browse files Browse the repository at this point in the history
  • Loading branch information
Renrut5 committed Oct 10, 2024
2 parents 56f7b61 + b848c07 commit fd0aeb2
Show file tree
Hide file tree
Showing 9 changed files with 170 additions and 29 deletions.
40 changes: 40 additions & 0 deletions docs/user/advanced/graphql.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,46 @@ location name is now derived using variables.
GraphQLRecord(json={'data': {'locations': [{'id': ..., 'name': 'HQ', 'parent': {'name': 'US'}}]}}, status_code=200)
```

## Making a GraphQL Query with a Saved Query

Nautobot supports saving your graphql queries and executing them later.
Here is an example of saving a query using pynautobot.

```python
>>> query = """
... query ($location_name:String!) {
... locations (name: [$location_name]) {
... id
... name
... parent {
... name
... }
... }
... }
... """
>>> data = {"name": "Foobar", "query": query}
>>> nautobot.extras.graphql_queries.create(**data)
```

Now that we have a query saved with the name `Foobar`, we can execute it using pynautobot.

```python
>>> # Create a variables dictionary
>>> variables = {"location_name": "HQ"}
>>>
>>> # Get the query object that was created
>>> query_object = nautobot.extras.graphql_queries.get("Foobar")
>>>
>>> # Call the run method to execute query
>>> query_object.run()
>>>
>>> # To execute a query with variables
>>> query_object.run(variables=variables)
>>>
>>> # To execute a query with custom payload
>>> query_object.run({"variables": variables, "foo": "bar"})
```

## The GraphQLRecord Object

The `~pynautobot.core.graphql.GraphQLRecord`{.interpreted-text
Expand Down
4 changes: 3 additions & 1 deletion pynautobot/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import logging

from pynautobot.core.endpoint import Endpoint, JobsEndpoint
from pynautobot.core.endpoint import Endpoint, JobsEndpoint, GraphqlEndpoint
from pynautobot.core.query import Request
from pynautobot.models import circuits, dcim, extras, ipam, users, virtualization

Expand Down Expand Up @@ -63,6 +63,8 @@ def __setstate__(self, d):
def __getattr__(self, name):
if name == "jobs":
return JobsEndpoint(self.api, self, name, model=self.model)
elif name == "graphql_queries":
return GraphqlEndpoint(self.api, self, name, model=self.model)
return Endpoint(self.api, self, name, model=self.model)

def __dir__(self):
Expand Down
63 changes: 45 additions & 18 deletions pynautobot/core/endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def _lookup_ret_obj(self, name, model):
ret = Record
return ret

def all(self, api_version=None, limit=None, offset=None):
def all(self, *args, **kwargs):
"""Queries the 'ListView' of a given endpoint.
Returns all objects from an endpoint.
Expand All @@ -103,21 +103,7 @@ def all(self, api_version=None, limit=None, offset=None):
>>> nb.dcim.devices.all()
[test1-a3-oobsw2, test1-a3-oobsw3, test1-a3-oobsw4]
"""
if not limit and offset is not None:
raise ValueError("offset requires a positive limit value")
api_version = api_version or self.api.api_version
req = Request(
base="{}/".format(self.url),
token=self.token,
http_session=self.api.http_session,
threading=self.api.threading,
max_workers=self.api.max_workers,
api_version=api_version,
limit=limit,
offset=offset,
)

return response_loader(req.get(), self.return_obj, self)
return self.filter(*args, **kwargs)

def get(self, *args, **kwargs):
"""Queries the DetailsView of a given endpoint.
Expand Down Expand Up @@ -224,8 +210,6 @@ def filter(self, *args, api_version=None, **kwargs):
if args:
kwargs.update({"q": args[0]})

if not kwargs:
raise ValueError("filter must be passed kwargs. Perhaps use all() instead.")
if any(i in RESERVED_KWARGS for i in kwargs):
raise ValueError("A reserved {} kwarg was passed. Please remove it " "try again.".format(RESERVED_KWARGS))
limit = kwargs.pop("limit") if "limit" in kwargs else None
Expand Down Expand Up @@ -758,3 +742,46 @@ def run_and_wait(self, *args, api_version=None, interval=5, max_rechecks=50, **k
return response_loader(req, self.return_obj, self)

raise ValueError("Did not receieve completed job result for job.")

class GraphqlEndpoint(Endpoint):
"""Extend Endpoint class to support run method for graphql queries."""

def run(self, query_id, *args, **kwargs):
"""Runs a saved graphql query based on the query_id provided.
Takes a kwarg of `query_id` to specify the query that should be run.
Args:
*args (str, optional): Used as payload for POST method
to the API if provided.
**kwargs (str, optional): Any additional argument the
endpoint accepts can be added as a keyword arg.
query_id (str, required): The UUID of the query object
that is being ran.
Returns:
An API response from the execution of the saved graphql query.
Examples:
To run a query no variables:
>>> query = nb.extras.graphql_queries.get("Example")
>>> query.run()
To run a query with `variables` as kwarg:
>>> query = nb.extras.graphql_queries.get("Example")
>>> query.run(
variables={"foo": "bar"})
)
To run a query with JSON payload as an arg:
>>> query = nb.extras.graphql_queries.get("Example")
>>> query.run(
{"variables":{"foo":"bar"}}
)
"""
query_run_url = f"{self.url}/{query_id}/run/"
return Request(
base=query_run_url,
token=self.token,
http_session=self.api.http_session,
).post(args[0] if args else kwargs)
8 changes: 7 additions & 1 deletion pynautobot/models/extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
#
# This file has been modified by NetworktoCode, LLC.

from pynautobot.core.endpoint import JobsEndpoint, DetailEndpoint
from pynautobot.core.endpoint import JobsEndpoint, DetailEndpoint, GraphqlEndpoint
from pynautobot.core.response import JsonField, Record


Expand Down Expand Up @@ -44,6 +44,12 @@ def run(self, **kwargs):
return JobsEndpoint(self.api, self.api.extras, "jobs").run(class_path=self.id, **kwargs)


class GraphqlQueries(Record):
def run(self, *args, **kwargs):
"""Run a graphql query from a saved graphql instance."""
return GraphqlEndpoint(self.api, self.api.extras, "graphql_queries").run(self.id, *args, **kwargs)


class DynamicGroups(Record):
def __str__(self):
parent_record_string = super().__str__()
Expand Down
2 changes: 2 additions & 0 deletions tests/integration/test_dcim.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ def test_fetching_vc_success(self, nb_client):
role={"name": "Leaf Switch"},
location=location.id,
status={"name": "Active"},
local_config_context_data={"foo": "bar"},
)
vc = nb_client.dcim.virtual_chassis.create(name="VC1", master=dev1.id)
nb_client.dcim.devices.create(
Expand All @@ -193,6 +194,7 @@ def test_fetching_vc_success(self, nb_client):
status={"name": "Active"},
virtual_chassis=vc.id,
vc_position=2,
local_config_context_data={"foo": "bar"},
)
all_vcs = nb_client.dcim.virtual_chassis.all()
assert len(all_vcs) == 1
Expand Down
16 changes: 16 additions & 0 deletions tests/integration/test_endpoint.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
class TestEndpoint:
"""Verify different methods on an endpoint."""

def test_get_all_devices(self, nb_client):
devices = nb_client.dcim.devices.all()
assert len(devices) == 8

def test_get_all_devices_include_context(self, nb_client):
devices = nb_client.dcim.devices.all(name="dev-1", include=["config_context"])
assert devices[0].config_context == {"foo": "bar"}

def test_get_filtered_devices(self, nb_client):
devices = nb_client.dcim.devices.filter(device_type="DCS-7050TX3-48C8")
assert len(devices) == 6


class TestPagination:
"""Verify we can limit and offset results on an endpoint."""

Expand Down
29 changes: 29 additions & 0 deletions tests/integration/test_extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,32 @@ def test_get_note_on_invalid_object(self, nb_client):
test_obj = nb_client.extras.content_types.get(model="manufacturer")
with pytest.raises(Exception, match="The requested url: .* could not be found."):
test_obj.notes.list()


class TestGraphqlQueries:
"""Verify we can create and run a saved graphql query"""

@pytest.fixture(scope="session")
def create_graphql_query(self, nb_client):
query = """
query Example($devicename: [String] = "server.networktocode.com"){
devices(name: $devicename) {
name
serial
}
}
"""
data = {"name": "foobar", "query": query}
return nb_client.extras.graphql_queries.create(**data)

def test_graphql_query_run(self, create_graphql_query):
query = create_graphql_query
data = query.run()
assert len(data.get("data", {}).get("devices")) == 1
assert data.get("data", {}).get("devices")[0].get("name") == "server.networktocode.com"

def test_graphql_query_run_with_variable(self, create_graphql_query):
query = create_graphql_query
data = query.run(variables={"devicename": "dev-1"})
assert len(data.get("data", {}).get("devices")) == 1
assert data.get("data", {}).get("devices")[0].get("name") == "dev-1"
2 changes: 1 addition & 1 deletion tests/integration/test_ipam.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@ def test_prefixes_successfully_stringify_tags(nb_client):
prefix="192.0.2.0/24", namespace={"name": "Global"}, status={"name": "Active"}, tags=[tag.id]
)
prefix = nb_client.ipam.prefixes.get(prefix="192.0.2.0/24", namespace="Global")
assert str(prefix) == "192.0.2.0/24"
assert "192.0.2.0/24" in str(prefix)
assert prefix.tags
assert isinstance(prefix.tags[0], Record)
35 changes: 27 additions & 8 deletions tests/unit/test_endpoint.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import unittest
from unittest.mock import Mock, patch

from pynautobot.core.endpoint import Endpoint, JobsEndpoint
from pynautobot.core.endpoint import Endpoint, JobsEndpoint, GraphqlEndpoint
from pynautobot.core.response import Record


Expand All @@ -15,13 +15,6 @@ def test_filter(self):
test = test_obj.filter(test="test")
self.assertEqual(len(test), 2)

def test_filter_empty_kwargs(self):
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
with self.assertRaises(ValueError) as _:
test_obj.filter()

def test_filter_reserved_kwargs(self):
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
Expand All @@ -38,6 +31,22 @@ def test_all_none_limit_offset(self):
with self.assertRaises(ValueError) as _:
test_obj.all(limit=None, offset=1)

def test_all_equals_filter_empty_kwargs(self):
with patch("pynautobot.core.query.Request.get", return_value=Mock()) as mock:
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
mock.return_value = [{"id": 123}, {"id": 321}]
test_obj = Endpoint(api, app, "test")
self.assertEqual(test_obj.all(), test_obj.filter())

def test_all_accepts_kwargs(self):
with patch("pynautobot.core.endpoint.Endpoint.filter", return_value=Mock()) as mock:
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
test_obj.all(include=["config_context"])
mock.assert_called_with(include=["config_context"])

def test_filter_zero_limit_offset(self):
with patch("pynautobot.core.query.Request.get", return_value=Mock()) as mock:
api = Mock(base_url="http://localhost:8000/api")
Expand Down Expand Up @@ -284,3 +293,13 @@ def test_run_and_wait_no_complete(self, mock_post, mock_get):
mock_get.return_value = {"schedule": {"id": 123}, "job_result": {"id": 123, "status": {"value": "PENDING"}}}
with self.assertRaises(ValueError):
test_obj.run_and_wait(job_id="test", interval=1, max_rechecks=2)

class GraphqlEndPointTestCase(unittest.TestCase):
def test_invalid_arg(self):
with self.assertRaises(
TypeError, msg="GraphqlEndpoint.run() missing 1 required positional argument: 'query_id'"
):
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = GraphqlEndpoint(api, app, "test")
test_obj.run()

0 comments on commit fd0aeb2

Please sign in to comment.