Repository: ucfopen/canvasapi (python)

Issue:
Add support for GraphQL endpoint
**What endpoints need to be covered?**
[GraphQL](https://canvas.instructure.com/doc/api/file.graphql.html) is a new endpoint that would be nice if it had a way to query it via this API. 

Initial implementation would just be to support this endpoint `POST /api/graphql` for making raw JSON POST requests and getting back a dict response. Future enhancements might be to switch over existing calls to optionally use this endpoint rather than going through the Canvas API methods for improved performance. 

I'm working on a proof or concept that I could use some review on.

Pull request description:
issue/354 graphql support
# Proposed Changes

* Add support for the Canvas GraphQL Endpoint
* Make the API version optional defaulting to v1 (GraphQL doesn't currently use an API version and it's possible there may be future API's on v2, v3, etc.

Feedback is appreciated. Most of the difficult changes here were around the v1 being hardcoded and needing to pass the query as one JSON string rather than form parameters.

Fixes #354 


Patch:
diff --git a/canvasapi/canvas.py b/canvasapi/canvas.py
index 1ce81bd..13b7aad 100644
--- a/canvasapi/canvas.py
+++ b/canvasapi/canvas.py
@@ -64,8 +64,6 @@ class Canvas(object):
         # the API.
         access_token = access_token.strip()
 
-        base_url = new_url + "/api/v1/"
-
         self.__requester = Requester(base_url, access_token)
 
     def clear_course_nicknames(self):
@@ -1184,6 +1182,33 @@ class Canvas(object):
             _kwargs=combine_kwargs(**kwargs),
         )
 
+    def graphql(self, query, variables=None, **kwargs):
+        """
+        Makes a GraphQL formatted request to Canvas
+
+        :calls: `POST /api/graphql \
+        <https://canvas.instructure.com/doc/api/file.graphql.html>`_
+
+        :param query: The GraphQL query to execute as a String
+        :type query: str
+        :param variables: The variable values as required by the supplied query
+        :type variables: dict
+
+        :rtype: dict
+        """
+        response = self.__requester.request(
+            "POST",
+            "graphql",
+            headers={"Content-Type": "application/json"},
+            _kwargs=combine_kwargs(**kwargs)
+            + [("query", query), ("variables", variables)],
+            # Needs to call special endpoint without api/v1
+            _url=self.__requester.original_url + "/api/graphql",
+            json=True,
+        )
+
+        return response.json()
+
     def list_appointment_groups(self, **kwargs):
         """
         List appointment groups.
diff --git a/canvasapi/exceptions.py b/canvasapi/exceptions.py
index e68f232..e118bc5 100644
--- a/canvasapi/exceptions.py
+++ b/canvasapi/exceptions.py
@@ -65,3 +65,9 @@ class Conflict(CanvasException):
     """Canvas had a conflict with an existing resource."""
 
     pass
+
+
+class UnprocessableEntity(CanvasException):
+    """Canvas was unable to process the entity."""
+
+    pass
diff --git a/canvasapi/requester.py b/canvasapi/requester.py
index 36ad89c..fbb3cf9 100644
--- a/canvasapi/requester.py
+++ b/canvasapi/requester.py
@@ -13,6 +13,7 @@ from canvasapi.exceptions import (
     InvalidAccessToken,
     ResourceDoesNotExist,
     Unauthorized,
+    UnprocessableEntity,
 )
 from canvasapi.util import clean_headers
 
@@ -32,12 +33,14 @@ class Requester(object):
         :param access_token: The API key to authenticate requests with.
         :type access_token: str
         """
-        self.base_url = base_url
+        # Preserve the original base url and add "/api/v1" to it
+        self.original_url = base_url
+        self.base_url = base_url + "/api/v1/"
         self.access_token = access_token
         self._session = requests.Session()
         self._cache = []
 
-    def _delete_request(self, url, headers, data=None):
+    def _delete_request(self, url, headers, data=None, **kwargs):
         """
         Issue a DELETE request to the specified endpoint with the data provided.
 
@@ -50,7 +53,7 @@ class Requester(object):
         """
         return self._session.delete(url, headers=headers, data=data)
 
-    def _get_request(self, url, headers, params=None):
+    def _get_request(self, url, headers, params=None, **kwargs):
         """
         Issue a GET request to the specified endpoint with the data provided.
 
@@ -63,7 +66,7 @@ class Requester(object):
         """
         return self._session.get(url, headers=headers, params=params)
 
-    def _patch_request(self, url, headers, data=None):
+    def _patch_request(self, url, headers, data=None, **kwargs):
         """
         Issue a PATCH request to the specified endpoint with the data provided.
 
@@ -76,7 +79,7 @@ class Requester(object):
         """
         return self._session.patch(url, headers=headers, data=data)
 
-    def _post_request(self, url, headers, data=None):
+    def _post_request(self, url, headers, data=None, json=False):
         """
         Issue a POST request to the specified endpoint with the data provided.
 
@@ -86,7 +89,11 @@ class Requester(object):
         :type headers: dict
         :param data: The data to send with this request.
         :type data: dict
+        :param json: Whether or not to send the data as json
+        :type json: bool
         """
+        if json:
+            return self._session.post(url, headers=headers, json=dict(data))
 
         # Grab file from data.
         files = None
@@ -103,7 +110,7 @@ class Requester(object):
 
         return self._session.post(url, headers=headers, data=data, files=files)
 
-    def _put_request(self, url, headers, data=None):
+    def _put_request(self, url, headers, data=None, **kwargs):
         """
         Issue a PUT request to the specified endpoint with the data provided.
 
@@ -124,6 +131,7 @@ class Requester(object):
         use_auth=True,
         _url=None,
         _kwargs=None,
+        json=False,
         **kwargs
     ):
         """
@@ -146,6 +154,10 @@ class Requester(object):
         :param _kwargs: A list of 2-tuples representing processed
             keyword arguments to be sent to Canvas as params or data.
         :type _kwargs: `list`
+        :param json: Whether or not to treat the data as json instead of form data.
+            currently only the POST request of GraphQL is using this parameter.
+            For all other methods it's just passed and ignored.
+        :type json: `bool`
         :rtype: str
         """
         full_url = _url if _url else "{}{}".format(self.base_url, endpoint)
@@ -194,7 +206,7 @@ class Requester(object):
         if _kwargs:
             logger.debug("Data: {data}".format(data=pformat(_kwargs)))
 
-        response = req_method(full_url, headers, _kwargs)
+        response = req_method(full_url, headers, _kwargs, json=json)
         logger.info(
             "Response: {method} {url} {status}".format(
                 method=method, url=full_url, status=response.status_code
@@ -231,6 +243,8 @@ class Requester(object):
             raise ResourceDoesNotExist("Not Found")
         elif response.status_code == 409:
             raise Conflict(response.text)
+        elif response.status_code == 422:
+            raise UnprocessableEntity(response.text)
         elif response.status_code > 400:
             # generic catch-all for error codes
             raise CanvasException(
diff --git a/docs/exceptions.rst b/docs/exceptions.rst
index c8ffec5..cc3419b 100644
--- a/docs/exceptions.rst
+++ b/docs/exceptions.rst
@@ -21,6 +21,8 @@ Quick Guide
 +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+
 | :class:`~canvasapi.exceptions.Conflict`             | 409             | Canvas had a conflict with an existing resource.                                |
 +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+
+| :class:`~canvasapi.exceptions.UnprocessableEntity`  | 422             | Canvas was unable to process the request.                                       |
++-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+
 | :class:`~canvasapi.exceptions.RequiredFieldMissing` | N/A             | A required keyword argument was not included.                                   |
 +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+
 | :class:`~canvasapi.exceptions.CanvasException`      | N/A             | An unknown error was thrown.                                                    |


Test patch:
diff --git a/tests/fixtures/graphql.json b/tests/fixtures/graphql.json
new file mode 100644
index 0000000..5f19eaf
--- /dev/null
+++ b/tests/fixtures/graphql.json
@@ -0,0 +1,21 @@
+{
+	"graphql": {
+		"method": "POST",
+		"endpoint": "graphql",
+		"data": {
+			"term": {
+				"coursesConnection": {
+				  "nodes": [
+					{
+						"_id": "1",
+						"assignmentsConnection": {
+							"nodes": []
+						}
+					}
+					]
+				}
+			}
+		},
+		"status_code": 200
+	}
+}
\ No newline at end of file
diff --git a/tests/fixtures/requests.json b/tests/fixtures/requests.json
index 5d00c69..ca123fc 100644
--- a/tests/fixtures/requests.json
+++ b/tests/fixtures/requests.json
@@ -32,6 +32,12 @@
 		"data": {},
 		"status_code": 409
 	},
+	"422": {
+		"method": "ANY",
+		"endpoint": "422",
+		"data": {},
+		"status_code": 422
+	},
 	"500": {
 		"method": "ANY",
 		"endpoint": "500",
diff --git a/tests/settings.py b/tests/settings.py
index 4fbbcab..f5dd44f 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -1,6 +1,7 @@
 from __future__ import absolute_import, division, print_function, unicode_literals
 
 BASE_URL = "https://example.com"
+BASE_URL_GRAPHQL = "https://example.com/api/"
 BASE_URL_WITH_VERSION = "https://example.com/api/v1/"
 BASE_URL_AS_HTTP = "http://example.com"
 BASE_URL_AS_BLANK = ""
diff --git a/tests/test_canvas.py b/tests/test_canvas.py
index 7a5a78d..77e56be 100644
--- a/tests/test_canvas.py
+++ b/tests/test_canvas.py
@@ -832,3 +832,34 @@ class TestCanvas(unittest.TestCase):
         self.assertEqual(comm_messages[0].subject, "example subject line")
         self.assertEqual(comm_messages[1].id, 2)
         self.assertEqual(comm_messages[1].subject, "My Subject")
+
+    def test_graphql(self, m):
+        register_uris({"graphql": ["graphql"]}, m, base_url=settings.BASE_URL_GRAPHQL)
+        query = """
+        query MyQuery($termid: ID!) {
+            term(id: $termid) {
+                coursesConnection {
+                    nodes {
+                        _id
+                        assignmentsConnection {
+                        nodes {
+                            name
+                            _id
+                            expectsExternalSubmission
+                        }
+                        }
+                    }
+                    edges {
+                        node {
+                        id
+                        }
+                    }
+                }
+            }
+        }
+        """
+        variables = {"termid": 125}
+
+        graphql_response = self.canvas.graphql(query=query, variables=variables)
+        # Just a super simple check right now that it gets back a dict respose
+        self.assertIsInstance(graphql_response, dict)
diff --git a/tests/test_requester.py b/tests/test_requester.py
index bd17c11..3225f33 100644
--- a/tests/test_requester.py
+++ b/tests/test_requester.py
@@ -14,6 +14,7 @@ from canvasapi.exceptions import (
     InvalidAccessToken,
     ResourceDoesNotExist,
     Unauthorized,
+    UnprocessableEntity,
 )
 from tests import settings
 from tests.util import register_uris
@@ -145,6 +146,12 @@ class TestRequester(unittest.TestCase):
         with self.assertRaises(Conflict):
             self.requester.request("GET", "409")
 
+    def test_request_422(self, m):
+        register_uris({"requests": ["422"]}, m)
+
+        with self.assertRaises(UnprocessableEntity):
+            self.requester.request("GET", "422")
+
     def test_request_500(self, m):
         register_uris({"requests": ["500"]}, m)
 
diff --git a/tests/util.py b/tests/util.py
index 64d7cee..ef5dd70 100644
--- a/tests/util.py
+++ b/tests/util.py
@@ -4,11 +4,10 @@ import os
 
 import requests_mock
 
-from canvasapi.util import get_institution_url
 from tests import settings
 
 
-def register_uris(requirements, requests_mocker):
+def register_uris(requirements, requests_mocker, base_url=None):
     """
     Given a list of required fixtures and an requests_mocker object,
     register each fixture as a uri with the mocker.
@@ -17,6 +16,8 @@ def register_uris(requirements, requests_mocker):
     :param requirements: dict
     :param requests_mocker: requests_mock.mocker.Mocker
     """
+    if base_url is None:
+        base_url = settings.BASE_URL_WITH_VERSION
     for fixture, objects in requirements.items():
         try:
             with open("tests/fixtures/{}.json".format(fixture)) as file:
@@ -39,11 +40,7 @@ def register_uris(requirements, requests_mocker):
             if obj["endpoint"] == "ANY":
                 url = requests_mock.ANY
             else:
-                url = (
-                    get_institution_url(settings.BASE_URL)
-                    + "/api/v1/"
-                    + obj["endpoint"]
-                )
+                url = base_url + obj["endpoint"]
 
             try:
                 requests_mocker.register_uri(