hypothesis / lms

LTI app for integrating with learning management systems
BSD 2-Clause "Simplified" License
46 stars 14 forks source link

SpeedGrader 0/n: Proxy API for making a submission to Canvas, for grading #846

Closed seanh closed 5 years ago

seanh commented 5 years ago

Belongs to epic: MVP Canvas SpeedGrader Support

To get Canvas to launch Hypothesis in the SpeedGrader, Hypothesis first needs to submit a grading URL to Canvas. For any given assignment and student, if Hypothesis has submitted a grading URL for that assignment and student, then Canvas will use that grading URL to launch Hypothesis when an instructor views that assignment and student in SpeedGrader.

In order to submit a grading URL to Canvas when a student launches an assignment, we first need to implement a server-side submit-grading-URL proxy API that our frontend code can call.

See Also

Why we need a proxy API for this

It's important that, when the LMS server receives a launch request from Canvas, it doesn't make any HTTP requests to external services synchronously as part of responding to that original request that our server received. For example the server mustn't make synchronous requests to the Canvas API, or to Canvas's grading submission URL. This is because Canvas sites and their response times are out of our control, and could cause our server's responses to become too slow (especially if the server had to make multiple requests to third-party services before sending back its response).

Instead the LMS app backend provides a number of proxy APIs for its own frontend to call, so that the LMS app's frontend code can ask its backend to make requests to Canvas for it. Each proxy API endpoint makes one request to an external service and returns the wrapped response to our frontend. The frontend code can't simply make these requests itself, because the requests require using Canvas developer keys (client_secrets) and access tokens which we want to keep on the backend, and not make directly available to the frontend, for security reasons.

Where the existing proxy API code in the LMS app is

How authenticating to the proxy API works

When the LMS app receives a launch request it responds to the LMS with an HTML page. This HTML page contains a Preact SPA. The server renders a JWT into the HTML, which the Preact's JavaScript code can use in the Authorization header of an XHR request to authenticate itself to the proxy API. The JWT in the HTML looks like this:

<script type="application/json" class="js-config">{"authToken": "Bearer ***"}</script>

When the frontend code sends a request with this bearer token in the Authorization header, that gets picked up by the app's authentication policies, which create a request.lti_user object based on the JWT.

request.lti_user is an instance of the LTIUser class which currently specifies the user's LTI user_id, oauth_consumer_key and roles.

Code elsewhere in the app then makes use of request.lti_user in order to know who the user is.

You may need to add more attributes to LTIUser

As you can see from the example code below, making a grading submission to Canvas requires at least:

  1. The lis_outcome_service_url LTI launch param
  2. The lis_result_sourcedid launch param
  3. The oauth_consumer_key launch param
  4. The user_id launch param
  5. The document URL of the assignment

It requires at least those 5. I might have missed some.

But the LTIUser class currently only specifies the user_id, oauth_consumer_key and roles, so those fields (and fields that we can derive from those) are all we know about the user who is making a proxy API request.

If more information about the user is needed to make the submission then we may need to add more fields to LTIUser, and update the validation schemas that serialize and deserialize LTIUser values.

Note that some of the LTI launch params needed for making a submission are only included in the launch params when a student launches an assignment, not when a teacher launches one. See below.

How to make a grading submission request to Canvas

Here's some hacky but working proof of concept code for submitting an LTI launch URL to Canvas for grading. For more details see the Canvas docs: https://canvas.instructure.com/doc/api/file.assignment_tools.html

Notes:

def lti_submit(request, document_url):
    try:
        lis_outcome_service_url = request.params["lis_outcome_service_url"]
        lis_result_sourcedid = request.params["lis_result_sourcedid"]
    except KeyError:
        return
    consumer_key = request.lti_user.oauth_consumer_key

    from lms.models import ApplicationInstance

    secret = (
        request.db.query(ApplicationInstance)
        .filter_by(consumer_key=consumer_key)
        .one()
        .shared_secret
    )

    from requests_oauthlib import OAuth1

    oauth_client = OAuth1(
        client_key=consumer_key,
        client_secret=secret,
        signature_method="HMAC-SHA1",
        signature_type="auth_header",
        force_include_body=True,
    )

    import requests
    from urllib.parse import urlencode

    lti_launch_url = (
        f"http://localhost:8001/lti_launches?speedgrader=true&amp;url={document_url}&amp;grading_user={request.context.h_display_name}"
    )
    response = requests.post(
        url=lis_outcome_service_url,
        data=f"""<?xml version = "1.0" encoding = "UTF-8"?>
<imsx_POXEnvelopeRequest xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
  <imsx_POXHeader>
    <imsx_POXRequestHeaderInfo>
      <imsx_version>V1.0</imsx_version>
      <imsx_messageIdentifier>999999123</imsx_messageIdentifier>
    </imsx_POXRequestHeaderInfo>
  </imsx_POXHeader>
  <imsx_POXBody>
    <replaceResultRequest>
      <resultRecord>
        <sourcedGUID>
          <sourcedId>{ lis_result_sourcedid }</sourcedId>
        </sourcedGUID>
        <result>
          <resultData>
            <ltiLaunchUrl>{ lti_launch_url }</ltiLaunchUrl>
          </resultData>
        </result>
      </resultRecord>
    </replaceResultRequest>
  </imsx_POXBody>
</imsx_POXEnvelopeRequest>""",
        headers={"Content-Type": "application/xml"},
        auth=oauth_client,
    )

Questions

Is there a simple and robust way to avoid making multiple submissions of the same assignment, for the same student?

robertknight commented 5 years ago

Implemented in https://github.com/hypothesis/lms/pull/864 and https://github.com/hypothesis/lms/pull/865