Portrait of Christopher

Christopher Bennell

I’m a full-stack web developer specializing in Ruby on Rails and Education Technology. Get in touch.

Building an LTI DeepLinking Response in Rails

LTI DeepLinking is an LTI standard which enabled passing content from your LTI Tool back to the LMS. This allows, for example, an RCE editor button placement that will launch your tool and return rich content to be embedded in the editor.

This is part 3 of a 3-part series on LTI Launches. Check out the other articles:

According to 1EdTech, DeepLinking’s goal is to:

[Reduce] the time and complexity of setting up an LTI tool link and streamlining the process of adding content from third parties into a Tool Consumer.

1EdTech

This example will return HTML content, but the DeepLinking spec also allows for a few other formats, like Link, File, and Image.

A high-level overview of the process:

  • Handle the launch while saving some details from the launch for later use

  • Redirect to our tool, which allows the user to create some content.

  • Send a POST request back to the LMS, with a single parameter named JWT. This is our own signed, encoded JWT that includes all the required claims, as well as the content we want the LMS to embed.

Enhancing the LaunchContext Object

When we looked at handling the OIDC launch, we used an LTI::LaunchContext model to encapsulate access to some of the claims in the JWT. It turns out that we need to save some of those parameters for later, when we redirect back to the LMS. We can enhance our LaunchContext class to allow it to persist across the user’s session with our Tool. Let’s revise the LaunchContext class slightly.

I initially tried to save the whole object in the session, but the object turned out to be too large to comfortably fit in the session. We can, however, turn the LaunchContext into an ActiveRecord, so it can be saved in the database (we could also stick it in a memory cache, like Solid Cache, if available).

Note that this example is slightly different than the version in the other article, but it can be instantiated in the same way. The difference is that this version inherits from ActiveRecord, and the #build method calls #create rather than #new, so it is persisted to the database.

I’m serializing the custom field because I want some flexibility to add custom fields to the Developer Key, without needing to add additional columns to the table.

app/models/lti/launch_context.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
module LTI
  class LaunchContext < ApplicationRecord
    serialize :custom, JSON

    def user_sis_id
      custom[user_sis_id]
    end

    def course_sis_id
      custom[course_sis_id]
    end

    def self.build(payload)
      # Instantiate a LaunchContext and save to the database
      create(
        message_type: claim_value(message_type, payload),
        lti_version: claim_value(lti_version, payload),
        deployment_id: claim_value(deployment_id, payload),
        target_link_uri: claim_value(target_link_uri, payload),
        custom: claim_value(custom, payload),
        return_url: claim_value(launch_presentation, payload)[return_url],
        deep_link_return_url: payload[https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings][deep_link_return_url],
        aud: payload[aud],
        azp: payload[azp],
        iss: payload[iss],
        sub: payload[sub]
      )
    end

    def self.claim_value(claim, payload)
      payload[https://purl.imsglobal.org/spec/lti/claim/#{claim}]
    end

    # Schedule this in a Background Job to remove old records
    def self.cleanup(cutoff: 3.hours.ago)
      where(updated_at < ?, cutoff).delete_all
    end
  end
end
db/migrate/..._create_launch_contexts.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class CreateLaunchContexts < ActiveRecord::Migration[7.0]
  def change
    create_table :launch_contexts do |t|
      t.string :message_type
      t.string :lti_version
      t.string :deployment_id
      t.string :target_link_uri
      t.string :return_url
      t.string :custom
      t.string :deep_link_return_url
      t.string :aud
      t.string :azp
      t.string :iss
      t.string :sub

      t.timestamps
    end
  end
end

This example assumes custom variable substitution in the Developer Key:

1
2
user_sis_id=$Canvas.user.sisSourceId
course_sis_id=$Canvas.course.sisSourceId

As a reminder, we instantiate this object from our OIDCController. Additionally, we’ll save the ID of the persisted LaunchContext to the session for later use.

app

/controllers/oidc_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class OIDCController < ApplicationController
  def callback
    session[:lti_launch_context_id] = lti_launch_context.id
    # redirect to content
  end

  private

  def jwt_content
    @jwt_content ||= begin
      content = JWTContent.new(params[:id_token])
      content.verify(
        lms_platform_id: config[:lms_platform_id],
        tool_client_id: config[tool_client_id],
        nonce: session[:nonce]
      )
      content.id_token
    end
  end

  def lti_launch_context
    @lti_launch_context ||= LTI::LaunchContext.build(jwt_content)
  end

  def cleanup_session_params
    session.delete :state
    session.delete :nonce
  end

  def config
    # Provide these details
    {
      lms_platform_id: , # https://canvas.instructure.com
      tool_client_id:  # This value is specific to your account and the tool being launched
    }
  end
end

Preparing the Response

Let’s assume that we’re redirecting to a ToolsController, where we will present a form that allows the visitor to build some content, saved as a Tool model. When this form is submitted, it will POST back to the LMS with all our crafter JWT. From the user’s perspective, building the content might be a multi-step process, or allow some way to save work in progress.

Let’s break down some of the steps we will be following:

  1. Prepare a response form to submit our content to the LMS.
  2. Because we’ll be attaching some logic to our response form, we extract it to a ViewComponent to better handle these capabilities.
  3. Give our response form component the ability to render the output HTML and encode it in a JWT, which is what we send back to the LMS.
  4. Set up our controller to instantiate the required objects and pass them to the view.
  5. Render our component.

We give ourselves access to the LaunchContext that we’ve saved in the session.

app/controllers/tools_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class ToolController < ApplicationController
  before_action :set_tool

  def edit
    # Set up the request as needed
  end

  def update
    respond_to do |format|
      if @tool.update(tool_params)
        format.html { redirect_to edit_tool_url(@tool) }
      else
        format.html { render :edit, status: :unprocessable_entity }
      end
    end
  end

  private

  def set_tool
    @tool = Tool.load(...)
  end

  def tool_params
    # ...
  end

  def lti_launch_context
    # This is populated by the OIDC controller in #callback action
    @lti_launch_context ||= session.key?(:lti_launch_context_id) && LTI::LaunchContext.find_by(
      id: session[:lti_launch_context_id]
    )
  end
end

Let’s take a look at the template for our Tool update page. The essential thing we need to do is POST back to the LMS with our encoded content.

app/views/tools/edit.html.erb
1
2
3
4
<form method=post action=<%= return_url %> id=deep_link_reponse_form>
  <input type=hidden name=JWT value=<%= response_content %>>
  <input type=submit>
</form>

response_content represents the JWT that we’re sending to the LMS. We’ll cover that shortly. How do we determine return_url? The LMS provides this in the LTI Launch, in the deep_linking_settings claim.

1
[https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings][deep_link_return_url]

We’re already saving that value in our LaunchContext, so we can insert it into the form’s action.

We’re going to be adding a bit of logic to this form, so let’s wrap it in a ViewComponent. We’ll pass the LaunchContext in as a parameter, so we have access to the deep_link_return_url and a few other values we will need later. We pass it into the view and render it.

app/component/deep_link_response_form_component.rb
1
2
3
4
5
6
7
8
class DeepLinkResponseFormComponent < ApplicationComponent
  attr_accessor :return_url

  def initialize(launch_context:)
    @launch_context = launch_context
    @return_url = @launch_context.deep_link_return_url
  end
end
app/controllers/tools_controller.rb
1
2
3
4
5
6
7
8
9
10
11
class ToolsController < ApplicationController
  before_action :set_response_form, only: [:edit]
  ...
  private

  def set_response_form
    @response_form = DeepLinkResponseFormComponent.new(
      launch_context: lti_launch_context
    )
  end
end
app/components/deep_link_response_form_component.html.erb
1
2
3
<form method=post action=<%= return_url %> id=deep_link_reponse_form>
  <input type=hidden name=JWT value=<%= response_content %>>
</form>
app/views/tools/edit.html.erb
1
<%= render @response_form %>

Rendering Response Content

Now let’s talk about the response_content. Ideally, this should contain a rendered version of our Tool resource. Wouldn’t it be nice if we could get Rails to render the Tool for us, using our existing resource scaffolding and the Tool show view representation? Can we capture the output of ToolsController#show? Yes! But there are some hoops to jump through.

We can get Rails to give us the output of a controller action by calling #render on the controller class. We need two things:

  1. The name of the action we want to render.

  2. The parameters that the controller would pass to the view, as instance variables (known as assigns)

An important caveat (and shortcoming) with this approach is that the controller callbacks will not be called, and the controller action method itself (ToolsController#show) will not run. We have to be careful about how we use this technique. I would recommend not using this approach to call actions on other controllers. Because we’re handing this request from within the ToolsController, I think we can get away with it.

We’ll create yet another class to encapsulate this behaviour. It’s just a simple immutable value object with a single method, so we use Data.define.

app/models/lti/response_context.rb
1
2
3
4
5
6
7
module LTI
  ResponseContext = Data.define(:controller_class, :action, :assigns) do
    def render
      controller_class.render(action, assigns: assigns, layout: false)
    end
  end
end

Back to ToolsController. Let’s create a ResponseContext and pass it into a DeepLinkResponseFormComponent.

app/controllers/tools_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class ToolController < ApplicationController
  before_action :set_tool
  before_action :set_response_form, only: [:edit]

  def show
    # ...
  end

  def edit
    # Set up the request as needed
  end

  def update
    respond_to do |format|
      if @tool.update(tool_params)
        format.html { redirect_to edit_tool_url(@tool) }
      else
        format.html { render :edit, status: :unprocessable_entity }
      end
    end
  end

  private

  def set_tool
    @tool = Tool.load(...)
  end

  def tool_params
    # ...
  end

  def set_response_form
    @response_form = DeepLinkResponseFormComponent.new(
      launch_context: lti_launch_context,
      response_context: lti_response_context
    )
  end

  def lti_launch_context
    # This is populated by the OIDC controller in #callback action
    @lti_launch_context ||= session.key?(:lti_launch_context_id) && LTI::LaunchContext.find_by(
      id: session[:lti_launch_context_id]
    )
  end

  def lti_response_context
    @lti_response_context ||= LTI::ResponseContext.new(
      controller: self.class,
      action: :show,
      assigns: {
        # whatever instance variables the show template needs
        # ex:
        # tool: @tool
      }
    )
  end
end

Now our DeepLinkResponseFormComponent has everything it needs to render itself properly. Let’s tell it how to fetch the response HTML.

app/component/deep_link_response_form_component.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
class DeepLinkResponseFormComponent < ApplicationComponent
  attr_accessor :return_url

  def initialize(launch_context:, response_context:)
    @launch_context = launch_context
    @response_context = response_context
    @return_url = @launch_context.deep_link_return_url
  end

  def response_html
    @response_context.render
  end
end

And finally, we can prepare the encoded response. The LMS is expecting a JWT, so we rely, once again, on the json-jwt gem to do this for us, using some details from the initial LMS launch.

app/component/deep_link_response_form_component.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class DeepLinkResponseFormComponent < ApplicationComponent
  attr_accessor :return_url

  def initialize(launch_context:, response_context:)
    @launch_context = launch_context
    @response_context = response_context
    @return_url = @launch_context.deep_link_return_url
  end

  def response_content
    response_params = {
      aud: @launch_context.iss,
      azp: @launch_context.iss,
      iss: @launch_context.aud,
      nonce: SecureRandom.hex(16),
      iat: Time.now.to_i,
      exp: (Time.now + 600.seconds).to_i,
      https://purl.imsglobal.org/spec/lti/claim/deployment_id: @launch_context.deployment_id,
      https://purl.imsglobal.org/spec/lti/claim/message_type: LtiDeepLinkingResponse,
      https://purl.imsglobal.org/spec/lti/claim/version: 1.3.0,
      https://purl.imsglobal.org/spec/lti-dl/claim/content_items: [
        {
          type: html,
          html: response_html
        }
      ]
    }

    jwt = JSON::JWT.new response_params
    jwt.sign SSL.private_key
  end

  def response_html
    @response_context.render
  end
end

Oops, we also need to provide a signing key.

app/models/ssl.rb
1
2
3
4
5
6
7
8
9
class SSL
  def self.private_key
    @key ||= OpenSSL::PKey::RSA.new key_value
  end

  def self.key_value
    ENV.fetch(SSL_JWK_PRIVATE_KEY)
  end
end

Dynamic Updates

One additional requirement for my use-case was that the response form update itself dynamically. Essentially, the user loads the edit page once, performs a number of actions, then submits the form. I needed a way to get the form to update itself with the most up-to-date copy of the HTML content. Turbo Frames to the rescue.

The solution is fairly simple. We wrap the response form in a Turbo Frame, and tell the controller to respond to Turbo Streams. When the Tool form is submitted, it will refresh the page, including the response form and it’s encoded JWT.

We also add a button to submit the response form.

app/views/tools/edit.html.erb
1
2
3
4
5
6
7
8
9
<%= form_with(model: @tool, url: tool_path) do |form| %>
  <!-- Edit the Tool -->
  <%= form.submit Save %>
<% end %>


<%= turbo_frame_tag dom_id(@tool, export) do %>
  <%= render @response_form %>
<% end %>
app/controllers/tools_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
class ToolController < ApplicationController
  def update
    respond_to do |format|
      if @tool.update(tool_params)
        format.html { redirect_to edit_tool_url(@tool) }
        format.turbo_stream
      else
        format.html { render :edit, status: :unprocessable_entity }
      end
    end
  end
end

We also add a button to submit the response form. This can get a bit awkward depending on how you do your layout. Rails doesn’t like to render a form within a form (fair) so we create a submit button for the response form that explicitly targets that form using the form html attribute. We can stick that button anywhere, even within a different form, and it will always submit the response form itself.

app/views/tools/edit.html.erb
1
2
3
4
5
6
7
8
9
<%= form_with(model: @tool, url: tool_path) do |form| %>
  <!-- Edit the Tool -->
  <input type=submit value=Export to LMS form=deep_link_reponse_form>
  <%= form.submit Save %>
<% end %>

<%= turbo_frame_tag dom_id(@tool, export) do %>
  <%= render @response_form %>
<% end %>

Conclusion

There you have it, a DeepLinking response with some bells and whistles.

We’ve explored LTI DeepLinking in Rails, journeying through the intricacies of integrating this powerful functionality into our applications. Along the way, we’ve delved into handling JWT payloads, crafting dynamic responses, and leveraging the capabilities of Rails to ease our burden and enhance the user experience.

Happy coding!