Rails LTI Tool Provider - Send Scores back to Canvas

In our previous tutorial, we showed how to create a basic LTI Tool Provider such as an eBook or another application that works with a Tool Consumer like Canvas. But one of the major benefits of LTI is that it enables Instructors to add different kinds of assessments (such as an exercise of a unique type) that are not otherwise available in Tool Consumers like Canvas. In this tutorial, we show how to make your assessment application LTI compatible, and send the scores back to a Tool Consumer.

We have created a basic Ruby on Rails quiz application. By following the steps below, you can take the code for the stand-alone quiz application, or your own Rails application, and make it LTI compatible, sending the results of student's assessment back to any Tool Consumer.

This tutorial assumes that you have read through the basic steps for creating an LTI-compliant application, and that you have at least a little familiarity with Ruby on Rails.

Step 1: Add this Ruby Gem to the Gemfile of your Rails application. It helps in performing most LTI tasks, such as validating and authenticating LTI requests.

          gem 'ims-lti', '~> 1.1.8'


Step 2: Before tool consumers can send a request to your tool, they will have to add your app. To do so, they need a key and a secret. Create a new config file config/lti_settings.yml, and add a key and a secret to that file. The lti_settings.yml file should contain the following:

          production:
            quizkey: 'FirstSecret'
  
          development:
            quizkey: 'FirstSecret'

Once you share your unique keys and secrets with a tool consumer, it will be able to add your application. Here is an example of the interface that Canvas uses:

quiz keysecret


If you create a new settings file, you need to create a variable that loads the content from your settings file. Add this line in config/application.rb

          config.lti_settings = Rails.application.config_for(:lti_settings)
        
This variable will load the key and secret from your settings file.

You can change the quizkey and FirstSecret in this example to what you like but each key/secret pair should be distinct for each tool consumer. If you knew that 10 different institutes/instructors want to integrate your tool into their courses, you might create 10 key/pairs.


Step 3: Along with a key and a secret, the tool consumer will also need a url where it can send a request. For that purpose, create a controller named lti with a launch endpoint (so the url can say '..../lti/launch' for readability). This launch endpoint will receive the post request from the tool consumer, and we will validate and authenticate the LTI requests within this endpoint.


Step 4: We need to keep a check if our application is launched as an LTI application or not. If it is launched as an LTI application, then we will have to make a few changes to the normal behavior of our app. Typically, we might need to hide the header and footer that we display to other users, because the application will load in an iframe. In that context, it should look like a part of a Tool Consumer. Also, we will have to send scores back to the Tool Consumer. This might imply that we don't want to show the results to the user ourselves, such as displaying a results page. For example, our simple Quiz application will look like this if you do not remove the header from an LTI launch:

quiz no iframe


To fix this, add the following line at the beginning of the launch endpoint.
          session[:isLTI]=true
To hide the header, add the following in views/layout/application.html.erb after moving header code in the _header partial.

          render "layouts/header" unless session[:isLTI]
Now, when your application is launched inside a tool provider like Canvas, it will look like this:
quiz iframe



Step 5: When your app is launched from a Tool Consumer (such as Canvas), it will send a post request to your launch endpoint with a bunch of parameters. One of the received parameters in the request will be oauth_consumer_key. This key should be exactly the same as the one we defined in the settings.yml file. The first step in a request validation is to check whether this received key is present in your system. If the key is not present, then throw an error. Add the following code inside the launch endpoint for key validation:

          if not Rails.configuration.lti_settings[params[:oauth_consumer_key]]
            render :launch_error, status: 401
            return
          end 

The code above checks if the key is present in the settings variable you created in step 2. If the key is not present, then redirect the user to launch_error page. We will create this page later in this tutorial.


Step 6: If the key is present, then we move to the second step of validation, which is to (1) check whether the request is a valid LTI request, and (2) verify the authenticity of the Tool Consumer.

  1. To check if the request is a valid LTI request, we need to check if The POST request contains lti_message_type with a value of basic-lti-launch-request, lti_version with a value of LTI-1p0 for LTI 1, and a resource_link_id. If any of these are missing, then the request is not a valid LTI request.
  2. If this is a valid LTI request, then we need to validate its authenticity. An authentic LTI request will have oauth_signature along with the oauth_consumer_key . We have already validated in Step 4 if the key is present or not. The next step is to generate a signature from this key and its corresponding secret that we have stored, and compare it with oauth_signature. If the two signatures match, only then the request is valid.
Fortunately, the IMS-LTI gem will do both of these steps for us. It will validate the LTI request and will also perform OAuth authentication. Add the following code in the launch endpoint. The first part creates a Tool provider object with a key and a secret. The second part validates the request. If the validation or authentication fails, it will redirect the user to launch_error page.

          require 'oauth/request_proxy/action_controller_request'
          @provider = IMS::LTI::ToolProvider.new(
            params[:oauth_consumer_key],
            Rails.configuration.lti_settings[params[:oauth_consumer_key]],
            params
          )
  
          if not @provider.valid_request?(request)
            # the request wasn't validated
            render :launch_error, status: 401
            return
          end


Step 7: At this point, you have a valid and authentic LTI request. Now, our quiz application requires a user to be logged in order to take a quiz. But this application will be launched through Canvas, and a key goal of LTI is to provide a seamless experience to the user. Therefore, we will have to create a user account and log her in automatically. To do so, add the following code.

          @@launch_params=params;
          email = params[:lis_person_contact_email_primary]
          @user = User.where(email: email).first
          if @user.blank?
            @user = User.new(:username => email,
              :email => email,
              :password => email,
              :password_confirmation => email)
            if !@user.save
              puts @user.errors.full_messages.first
            end
          end
  
          #Login the user and create his session.
          authorized_user = User.authenticate(email,email)
          session[:user_id] = authorized_user.id
  
          #redirect the user to give quiz starting from question id 1
          redirect_to(:controller => "questions", :action => "show", :id => 1)
In the first line we save request parameters, because we will need those to submit scores back to the Tool Consumer. Then we check if any user with this email already exists in our database. If not, then we create a new user. After that, we login the user and create his session. Finally, we redirect the user to the quiz, starting from question id 1.


Step 8: Our quiz application normally redirects the user to a results page once she finishes all the questions. But when launched via LTI from within a tool consumer, we instead want to submit the score back without launching the page. T do this, modify the code at the end of the submitQuestion endpoint in the Questions controller to the following.

          if session[:isLTI]
            @@result = @@count.to_f/(@@count+@@falseCount)
            @@count = 0
            @@falseCount = 0
            redirect_to(:controller => "lti", :action => "submitscore", :result
            => @@result)
          else
            @@result = @@count
            @@count = 0
            @@falseCount = 0
            redirect_to(:action => "result")
          end
        
In this code, we check if the isLTI session variable is set. If so, then submit the score back to tool consumer, otherwise redirect the user to the results page. The score that we pass back must be in range of 0 to 1, which is why we divide total correct answers with total questions.


Step 9: Now create a new end point submitscore to submit the score back to tool consumer. Add the following code in the lti controller.

          def submitscore
            @tp = IMS::LTI::ToolProvider.new(
              @@launch_params[:oauth_consumer_key],
              Rails.configuration.lti_settings[@@launch_params
              [:oauth_consumer_key]],
              @@launch_params)
            # add extension
            @tp.extend IMS::LTI::Extensions::OutcomeData::ToolProvider
  
            if !@tp.outcome_service?
              @message = "This tool wasn't lunched as an outcome service"
              puts "This tool wasn't lunched as an outcome service"
              render(:launch_error)
            end
  
            res = @tp.post_extended_replace_result!(score: params[:result])
  
            if res.success?
              puts "Score Submitted"
            else
              puts "Error during score submission"
            end
          end
        
This code creates a tool provider object using the parameters we received in our request. We extend the tool provider object with the OutcomeData extension provided by the IMS-LTI gem.
First we have to check whether the tool was launched as an outcome service or not. This means that if the tool was launched as an assignment, then we have to send the final result of an assessment back to the tool consumer. Therefore, in its request it sends lis_outcome_service_url and lis_result_sourcedid. The fist parameter is a URL used to send the score back, and the second parameter identifies a unique row and column within the Tool Consumer's gradebook. If these two parameters are not present, then we redirect the user to an error page. Otherwise we post the result back to the tool consumer. The IMS-LTI gem enables us to accomplish all of this using 'post_extended_replace_result'.


Step 10: The Tool Consumer also tells us in the request where we should redirect the user once he completes the assessment in launch_presentation_return_url. Therefore, we redirect the user back to launch_presentation_return_url once we submit the scores. Add the following line at the end of the submitscore endpoint

          redirect_to @@launch_params[:launch_presentation_return_url]

Step 11: If you want an instructor to be able to register your application in Tool Consumer, you will need XML configuration for your app. Therefore, you should create a page that provides the XML configuration for your app. Following is the XML configuration for our quiz app.

          
            <cartridge_basiclti_link
              xmlns="http://www.imsglobal.org/xsd/imslticc_v1p0"
              xmlns:blti = "http://www.imsglobal.org/xsd/imsbasiclti_v1p0"
              xmlns:lticm ="http://www.imsglobal.org/xsd/imslticm_v1p0"
              xmlns:lticp ="http://www.imsglobal.org/xsd/imslticp_v1p0"
              xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance"
              xsi:schemaLocation = "http://www.imsglobal.org/xsd/imslticc_v1p0
              http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd
              http://www.imsglobal.org/xsd/imsbasiclti_v1p0
              http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0.xsd
              http://www.imsglobal.org/xsd/imslticm_v1p0
              http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd
              http://www.imsglobal.org/xsd/imslticp_v1p0
              http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd">
                <blti:title>Quiz</blti:title>
                <blti:description> Quiz  LTI Application</blti:description>
                <blti:icon></blti:icon>
                <blti:launch_url> http://localhost:3000/lti/launch
                </blti:launch_url>
                <blti:extensions platform="canvas.instructure.com">
                  <lticm:property name="privacy_level">public</lticm:property>
                </blti:extensions>
                <cartridge_bundle identifierref="BLTI001_Bundle"/>
                <cartridge_icon identifierref="BLTI001_Icon"/>
            </cartridge_basiclti_link>
          
        


Step 12: Now you have all of the things required to register an application in Tool Consumer: key, secret and XML configuration. After entering a URL of your XML file (In Canvas, you can also paste your XML content) along with the key and a secret in Tool Consumer, an instructor will be able see your application as an "External Tool" while creating an assignment or quiz.

quiz ext



Step 13: If you launch your application now to take an assessment, you will receive the following error:

invalid authenticity


To fix this, you need to tell Rails that it does not need to verify the user before a launch request. Add the following line in the application controller.

          skip_before_action :verify_authenticity_token, only: :launch


Step 14: If you launch your application once again, you should see the following warning if your application is not running on HTTPS.

quiz ssl warning


To add SSL certificate on localhost, follow the steps mentioned here.


Step 15: Now, delete your application from Canvas, update your config file with the https launch URL, and add your application again.


Step 16: Even now, if you launch your application, you will not see anything, but rather you will see the following error in your browser console:

          Refused to display 'https://localhost:3000/questions/1' in a frame
          because it set 'X-Frame-Options' to 'sameorigin'.
        

You see this error because Canvas opens the LTI Tool in an iframe, but Rails does not allow the application to be embedded in an iframe by default. Therefore, you need to add the following code in the questions controller.

          after_action :allow_iframe, only: [:show, :result]
  
          def allow_iframe
            response.headers.except! 'X-Frame-Options'
          end

In the first line of the code, we tell rails to only allow "show" and "result" endpoints to open in an iframe.


Step 17: The last step is to update routes and also to create a launch_error page, because this is where we are redirecting the user if the request is not validated or authenticated. Create views/lit/launch_error.html.erb and add the following code to it:

<h1>Lunch Error</h1> <p>Make sure you have a correct key and a secret to access the quiz application.</p>
Since Canvas will also open this launch error page in an iframe and we redirect to this page within the launch endpoint, we need to allow the launch endpoint to open in an iframe as well. Add the following code in the lti controller.

          after_action :allow_iframe, only: [:launch]
  
          def allow_iframe
          response.headers.except! 'X-Frame-Options'
          end
        

Now, if the request received is not validated or authenticated, you will see the the following on Canvas:

quiz launch error
Your routes.rb file at the end should somewhat look like this for our Quiz application:

          Rails.application.routes.draw do
            get 'quiz/index'
            resources :questions
            post 'questions/submitQuestion'=>'questions#submitQuestion', as:
            :submit_question
            root 'sessions#login'
            get "signup", :to => "users#new"
            get "login", :to => "sessions#login"
            get "logout", :to => "sessions#logout"
            get "home", :to => "sessions#home"
            get "profile", :to => "sessions#profile"
            get "setting", :to => "sessions#setting"
            post "signup", :to => "users#new"
            post "login", :to => "sessions#login"
            post "logout", :to => "sessions#logout"
            post "login_attempt", :to => "sessions#login_attempt"
            get "login_attempt", :to => "sessions#login_attempt"
            post "user_create", :to => "users#create"
            get "all", :to => "questions#index"
            get "result", :to => "questions#result"
            get 'lti/launch'
            post 'lti/launch'
            get 'lti/submitscore'
            post 'lti/submitscore'
          end
        


At this point you should have a working LTI-enabled quiz application. It will work as-is for non LTI launches (on your own domain), and will also work as an LTI tool when launched from within a tool consumer. If it is launched from within a tool consumer, then once a user submits an assessment, his scores can be recorded in the tool consumer's gradebook. On Canvas it will look like this:

quiz result


Here you can download the complete source code for a version of the quiz application with LTI support.