Repository: ucfopen/canvasapi (python)

Issue:
Add API coverage for LTI resource links
**What resource needs additional coverage?**
Courses `lti_resource links`, which is a new API which is in beta and will be released soon. 

**What endpoints need to be covered?**
Add support for these https://canvas.instructure.com/doc/api/lti_resource_links.html 
```
GET /api/v1/courses/:course_id/lti_resource_links
GET /api/v1/courses/:course_id/lti_resource_links/:id
PUT /api/v1/courses/:course_id/lti_resource_links/:id
```


Pull request description:
Issue/662 Add API coverage for LTI resource links
# Proposed Changes
* Add support for [LTI resource links](https://canvas.instructure.com/doc/api/lti_resource_links.html)
* Implement `get_lti_resource_links()`, `get_lti_resource_link()`, `create_lti_resouce_link()`
* Add associated tests/fixtures (see `test_courses.py`, `lti_resource_link.json`)

Fixes #662

# Effects
* With these changes, all tests are passing and coverage report remains at 100%.

**Note**: Local testing proved difficult as I do not have access to a paid Canvas instance where I can generate dummy LTI resource links to view actual server responses (referencing [here](https://www.instructure.com/canvas/free-for-teacher/account-comparison) and [here](https://community.canvaslms.com/t5/Free-For-Teacher-Canvas-Users/Developer-Key-Permissions-for-LTI-with-Free-Account/td-p/589930)). Resorted to using mock responses to test, but should revisit with access to a paid Canvas account.

Patch:
diff --git a/AUTHORS.md b/AUTHORS.md
index d2ac0bf..67b1d40 100644
--- a/AUTHORS.md
+++ b/AUTHORS.md
@@ -53,6 +53,7 @@
 - Ian Altgilbers [@altgilbers](https://github.com/altgilbers)
 - Ian Turgeon [@iturgeon](https://github.com/iturgeon)
 - [@jackrsteiner](https://github.com/jackrsteiner)
+- Jasmine Hou [@jsmnhou](https://github.com/jsmnhou)
 - John Raible [@rebelaide](https://github.com/rebelaide)
 - Joon Ro [@joonro](https://github.com/joonro)
 - Jonah Majumder [@jonahmajumder](https://github.com/jonahmajumder)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c0a7326..9a2b16d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 ## [Unreleased]
 
+### New Endpoint Coverage
+
+- LTI Resource Links (Thanks, [@jsmnhou](https://github.com/jsmnhou))
+
 ### Backstage
 
 - Updated deploy Action to use more modern processes.
diff --git a/canvasapi/course.py b/canvasapi/course.py
index 523a271..2b28f81 100644
--- a/canvasapi/course.py
+++ b/canvasapi/course.py
@@ -23,6 +23,7 @@ from canvasapi.gradebook_history import (
 from canvasapi.grading_period import GradingPeriod
 from canvasapi.grading_standard import GradingStandard
 from canvasapi.license import License
+from canvasapi.lti_resource_link import LTIResourceLink
 from canvasapi.module import Module
 from canvasapi.new_quiz import NewQuiz
 from canvasapi.outcome_import import OutcomeImport
@@ -438,6 +439,39 @@ class Course(CanvasObject):
 
         return LatePolicy(self._requester, late_policy_json["late_policy"])
 
+    def create_lti_resource_link(self, url, title=None, custom=None, **kwargs):
+        """
+        Create a new LTI resource link.
+
+        :calls: `POST /api/v1/courses/:course_id/lti_resource_links \
+        <https://canvas.instructure.com/doc/api/lti_resource_links.html#method.lti/resource_links.create>`_
+
+        :param url: The launch URL for the resource link.
+        :type url: `str`
+        :param title: The title of the resource link.
+        :type title: `str`, optional
+        :param custom: Custom parameters to send to the tool.
+        :type custom: `dict`, optional
+
+        :rtype: :class:`canvasapi.lti_resource_link.LTIResourceLink`
+        """
+
+        if not url:
+            raise RequiredFieldMissing("url is required as a str.")
+
+        kwargs["url"] = url
+        if title:
+            kwargs["title"] = title
+        if custom:
+            kwargs["custom"] = custom
+
+        response = self._requester.request(
+            "POST",
+            f"courses/{self.id}/lti_resource_links",
+            _kwargs=combine_kwargs(**kwargs),
+        )
+        return LTIResourceLink(self._requester, response.json())
+
     def create_module(self, module, **kwargs):
         """
         Create a new module.
@@ -1645,6 +1679,49 @@ class Course(CanvasObject):
             _kwargs=combine_kwargs(**kwargs),
         )
 
+    def get_lti_resource_link(self, lti_resource_link, **kwargs):
+        """
+        Return details about the specified resource link.
+
+        :calls: `GET /api/v1/courses/:course_id/lti_resource_links/:id \
+        <https://canvas.instructure.com/doc/api/lti_resource_links.html#method.lti/resource_links.show>`_
+
+        :param lti_resource_link: The object or ID of the LTI resource link.
+        :type lti_resource_link: :class:`canvasapi.lti_resource_link.LTIResourceLink` or int
+
+        :rtype: :class:`canvasapi.lti_resource_link.LTIResourceLink`
+        """
+
+        lti_resource_link_id = obj_or_id(
+            lti_resource_link, "lti_resource_link", (LTIResourceLink,)
+        )
+
+        response = self._requester.request(
+            "GET",
+            f"courses/{self.id}/lti_resource_links/{lti_resource_link_id}",
+            _kwargs=combine_kwargs(**kwargs),
+        )
+        return LTIResourceLink(self._requester, response.json())
+
+    def get_lti_resource_links(self, **kwargs):
+        """
+        Returns all LTI resource links for this course as a PaginatedList.
+
+        :calls: `GET /api/v1/courses/:course_id/lti_resource_links \
+        <https://canvas.instructure.com/doc/api/lti_resource_links.html#method.lti/resource_links.index>`_
+
+        :rtype: :class:`canvasapi.paginated_list.PaginatedList` of
+            :class:`canvasapi.lti_resource_link.LTIResourceLink`
+        """
+
+        return PaginatedList(
+            LTIResourceLink,
+            self._requester,
+            "GET",
+            f"courses/{self.id}/lti_resource_links",
+            kwargs=combine_kwargs(**kwargs),
+        )
+
     def get_migration_systems(self, **kwargs):
         """
         Return a list of migration systems.
diff --git a/canvasapi/lti_resource_link.py b/canvasapi/lti_resource_link.py
new file mode 100644
index 0000000..d951569
--- /dev/null
+++ b/canvasapi/lti_resource_link.py
@@ -0,0 +1,6 @@
+from canvasapi.canvas_object import CanvasObject
+
+
+class LTIResourceLink(CanvasObject):
+    def __str__(self):
+        return "{} ({})".format(self.url, self.title)
diff --git a/docs/class-reference.rst b/docs/class-reference.rst
index 7420abf..e73ade2 100644
--- a/docs/class-reference.rst
+++ b/docs/class-reference.rst
@@ -41,6 +41,7 @@ Class Reference
     jwt-ref
     login-ref
     license-ref
+    lti-resource-link-ref
     module-ref
     outcome-ref
     outcome-import-ref
diff --git a/docs/lti-resource-link-ref.rst b/docs/lti-resource-link-ref.rst
new file mode 100644
index 0000000..b14ff90
--- /dev/null
+++ b/docs/lti-resource-link-ref.rst
@@ -0,0 +1,6 @@
+===============
+LTIResourceLink
+===============
+
+.. autoclass:: canvasapi.lti_resource_link.LTIResourceLink
+    :members:


Test patch:
diff --git a/tests/fixtures/lti_resource_link.json b/tests/fixtures/lti_resource_link.json
new file mode 100644
index 0000000..2de1351
--- /dev/null
+++ b/tests/fixtures/lti_resource_link.json
@@ -0,0 +1,49 @@
+{
+	"create_lti_resource_link": {
+		"method": "POST",
+		"endpoint": "courses/1/lti_resource_links",
+		"data": {
+			"id": 45,
+            "context_id": 1,
+            "context_type": "Course",
+            "context_external_tool_id": 1,
+            "resource_type": "assignment",
+            "canvas_launch_url": "https://example.instructure.com/courses/1/external_tools/retrieve?resource_link_lookup_uuid=ae43ba23-d238-49bc-ab55-ba7f79f77896",
+            "resource_link_uuid": "ae43ba23-d238-49bc-ab55-ba7f79f77896",
+            "lookup_uuid": "c522554a-d4be-49ef-b163-9c87fdc6ad6f",
+            "title": "Test LTI Resource Link",
+            "url": "https://example.com/lti/launch/content_item/123"
+		},
+		"status_code": 200
+	},
+
+	"get_lti_resource_link": {
+		"method": "GET",
+		"endpoint": "courses/1/lti_resource_links/45",
+		"data": {
+			"id": 45,
+            "title": "Test LTI Resource Link",
+			"url": "https://example.com/lti/launch/content_item/123"
+		},
+		"status_code": 200
+	},
+	"list_lti_resource_links": {
+		"method": "GET",
+		"endpoint": "courses/1/lti_resource_links",
+		"data": [
+            {
+				"id": 45,
+                "title": "Test LTI Resource Link"
+			},
+			{
+				"id": 56,
+                "title": "Test LTI Resource Link 2"
+			},
+			{
+				"id": 67,
+                "title": "Test LTI Resource Link 3"
+			}
+		],
+		"status_code": 200
+	}
+}
diff --git a/tests/test_course.py b/tests/test_course.py
index 2e2669d..f21eb9f 100644
--- a/tests/test_course.py
+++ b/tests/test_course.py
@@ -35,6 +35,7 @@ from canvasapi.grading_period import GradingPeriod
 from canvasapi.grading_standard import GradingStandard
 from canvasapi.group import Group, GroupCategory
 from canvasapi.license import License
+from canvasapi.lti_resource_link import LTIResourceLink
 from canvasapi.module import Module
 from canvasapi.new_quiz import NewQuiz
 from canvasapi.outcome import OutcomeGroup, OutcomeLink, OutcomeResult
@@ -1890,6 +1891,48 @@ class TestCourse(unittest.TestCase):
         self.assertIsInstance(root_folder_list[0], Folder)
         self.assertEqual("course_files", root_folder_list[0].name)
 
+    # create_lti_resource_link()
+    def test_create_lti_resource_link(self, m):
+        register_uris({"lti_resource_link": ["create_lti_resource_link"]}, m)
+        custom_dict = {"hello": "world"}
+
+        evnt = self.course.create_lti_resource_link(
+            url="https://example.com/lti/launch/content_item/123",
+            title="Test LTI Resource Link",
+            custom=custom_dict,
+        )
+        self.assertIsInstance(evnt, LTIResourceLink)
+
+        self.assertEqual(evnt.title, "Test LTI Resource Link")
+        self.assertEqual(evnt.url, "https://example.com/lti/launch/content_item/123")
+
+    def test_create_lti_resource_link_fail(self, m):
+        with self.assertRaises(RequiredFieldMissing):
+            self.course.create_lti_resource_link({})
+
+    # get_lti_resource_links()
+    def test_get_lti_resource_links(self, m):
+        register_uris({"lti_resource_link": ["list_lti_resource_links"]}, m)
+
+        lti_resource_links = self.course.get_lti_resource_links()
+        lti_resource_link_list = [link for link in lti_resource_links]
+        self.assertEqual(len(lti_resource_link_list), 3)
+        self.assertIsInstance(lti_resource_link_list[0], LTIResourceLink)
+
+    # get_lti_resource_link()
+    def test_get_lti_resource_link(self, m):
+        register_uris({"lti_resource_link": ["get_lti_resource_link"]}, m)
+
+        lti_resource_link_by_id = self.course.get_lti_resource_link(45)
+
+        self.assertIsInstance(lti_resource_link_by_id, LTIResourceLink)
+        self.assertEqual(lti_resource_link_by_id.title, "Test LTI Resource Link")
+        lti_resource_link_by_obj = self.course.get_lti_resource_link(
+            lti_resource_link_by_id
+        )
+        self.assertIsInstance(lti_resource_link_by_obj, LTIResourceLink)
+        self.assertEqual(lti_resource_link_by_obj.title, "Test LTI Resource Link")
+
 
 @requests_mock.Mocker()
 class TestCourseNickname(unittest.TestCase):
diff --git a/tests/test_lti_resource_link.py b/tests/test_lti_resource_link.py
new file mode 100644
index 0000000..f1b5890
--- /dev/null
+++ b/tests/test_lti_resource_link.py
@@ -0,0 +1,30 @@
+import unittest
+
+import requests_mock
+
+from canvasapi import Canvas
+from tests import settings
+from tests.util import register_uris
+
+
+@requests_mock.Mocker()
+class TestLTIResourceLink(unittest.TestCase):
+    def setUp(self):
+        self.canvas = Canvas(settings.BASE_URL, settings.API_KEY)
+
+        with requests_mock.Mocker() as m:
+            register_uris(
+                {
+                    "course": ["get_by_id"],
+                    "lti_resource_link": ["get_lti_resource_link"],
+                },
+                m,
+            )
+
+            self.course = self.canvas.get_course(1)
+            self.resource_link = self.course.get_lti_resource_link(45)
+
+    # __str__()
+    def test__str__(self, m):
+        string = str(self.resource_link)
+        self.assertIsInstance(string, str)