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.
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.
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
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
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:
- Prepare a response form to submit our content to the LMS.
- Because we’ll be attaching some logic to our response form, we extract it to a ViewComponent to better handle these capabilities.
- 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.
- Set up our controller to instantiate the required objects and pass them to the view.
- Render our component.
We give ourselves access to the LaunchContext
that we’ve saved in the session.
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.
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.
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
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
1
2
3
<form method=post action=<%= return_url %> id=deep_link_reponse_form>
<input type=hidden name=JWT value=<%= response_content %>>
</form>
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:
-
The name of the action we want to render.
-
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
.
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
.
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.
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.
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.
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.
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 %>
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.
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!