aj-ims-lti 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ module AJIMS::LTI
2
+ # Class for consuming/generating LTI Outcome Responses
3
+ #
4
+ # Response documentation: http://www.imsglobal.org/lti/v1p1pd/ltiIMGv1p1pd.html#_Toc309649691
5
+ #
6
+ # Error code documentation: http://www.imsglobal.org/gws/gwsv1p0/imsgws_baseProfv1p0.html#1639667
7
+ #
8
+ # This class can be used by both Tool Providers and Tool Consumers. Each will
9
+ # use it a bit differently. The Tool Provider will use it parse the result of
10
+ # an OutcomeRequest to the Tool Consumer. A Tool Consumer will use it generate
11
+ # proper response XML to send back to a Tool Provider
12
+ #
13
+ # === Tool Provider Usage
14
+ # An OutcomeResponse will generally be created when POSTing an OutcomeRequest
15
+ # through a configured ToolProvider. See the ToolProvider documentation for
16
+ # typical usage.
17
+ #
18
+ # === Tool Consumer Usage
19
+ # When an outcome request is sent from a Tool Provider the body of the request
20
+ # is XML. This class parses that XML and provides a simple interface for
21
+ # accessing the information in the request. Typical usage would be:
22
+ #
23
+ # # create a new response and set the appropriate values
24
+ # res = IMS::LTI::OutcomeResponse.new
25
+ # res.message_ref_identifier = outcome_request.message_identifier
26
+ # res.operation = outcome_request.operation
27
+ # res.code_major = 'success'
28
+ # res.severity = 'status'
29
+ #
30
+ # # set a description (optional) and other information based on the type of response
31
+ # if outcome_request.replace_request?
32
+ # res.description = "Your old score of 0 has been replaced with #{outcome_request.score}"
33
+ # elsif outcome_request.read_request?
34
+ # res.description = "You score is 50"
35
+ # res.score = 50
36
+ # elsif outcome_request.delete_request?
37
+ # res.description = "You score has been cleared"
38
+ # else
39
+ # res.code_major = 'unsupported'
40
+ # res.severity = 'status'
41
+ # res.description = "#{outcome_request.operation} is not supported"
42
+ # end
43
+ #
44
+ # # the generated xml is returned to the Tool Provider
45
+ # res.generate_response_xml
46
+ #
47
+ class OutcomeResponse
48
+ include AJIMS::LTI::Extensions::Base
49
+
50
+ attr_accessor :request_type, :score, :message_identifier, :response_code,
51
+ :post_response, :code_major, :severity, :description, :operation,
52
+ :message_ref_identifier
53
+
54
+ CODE_MAJOR_CODES = %w{success processing failure unsupported}
55
+ SEVERITY_CODES = %w{status warning error}
56
+
57
+ # Create a new OutcomeResponse
58
+ #
59
+ # @param opts [Hash] initialization hash
60
+ def initialize(opts={})
61
+ opts.each_pair do |key, val|
62
+ self.send("#{key}=", val) if self.respond_to?("#{key}=")
63
+ end
64
+ end
65
+
66
+ # Convenience method for creating a new OutcomeResponse from a response object
67
+ #
68
+ # req = IMS::LTI::OutcomeResponse.from_post_response(response)
69
+ def self.from_post_response(post_response)
70
+ response = OutcomeResponse.new
71
+ response.process_post_response(post_response)
72
+ end
73
+
74
+ def process_post_response(post_response)
75
+ self.post_response = post_response
76
+ self.response_code = post_response.code
77
+ xml = post_response.body
78
+ self.process_xml(xml)
79
+ self
80
+ end
81
+
82
+ def success?
83
+ @code_major == 'success'
84
+ end
85
+
86
+ def processing?
87
+ @code_major == 'processing'
88
+ end
89
+
90
+ def failure?
91
+ @code_major == 'failure'
92
+ end
93
+
94
+ def unsupported?
95
+ @code_major == 'unsupported'
96
+ end
97
+
98
+ def has_warning?
99
+ @severity == 'warning'
100
+ end
101
+
102
+ def has_error?
103
+ @severity == 'error'
104
+ end
105
+
106
+ # Parse Outcome Response data from XML
107
+ def process_xml(xml)
108
+ doc = REXML::Document.new xml
109
+ @message_identifier = doc.text("//imsx_statusInfo/imsx_messageIdentifier").to_s
110
+ @code_major = doc.text("//imsx_statusInfo/imsx_codeMajor")
111
+ @code_major.downcase! if @code_major
112
+ @severity = doc.text("//imsx_statusInfo/imsx_severity")
113
+ @severity.downcase! if @severity
114
+ @description = doc.text("//imsx_statusInfo/imsx_description")
115
+ @description = @description.to_s if @description
116
+ @message_ref_identifier = doc.text("//imsx_statusInfo/imsx_messageRefIdentifier")
117
+ @operation = doc.text("//imsx_statusInfo/imsx_operationRefIdentifier")
118
+ @score = doc.text("//readResultResponse//resultScore/textString")
119
+ @score = @score.to_s if @score
120
+ rescue REXML::ParseException
121
+ raise AJIMS::LTI::XmlParseException.new(
122
+ xml,
123
+ "Failed to parse XML response from canvas"
124
+ )
125
+ end
126
+
127
+ # Generate XML based on the current configuration
128
+ # @return [String] The response xml
129
+ def generate_response_xml
130
+ builder = Builder::XmlMarkup.new
131
+ builder.instruct!
132
+
133
+ builder.imsx_POXEnvelopeResponse("xmlns" => "http://www.imsglobal.org/lis/oms1p0/pox") do |env|
134
+ env.imsx_POXHeader do |header|
135
+ header.imsx_POXResponseHeaderInfo do |info|
136
+ info.imsx_version "V1.0"
137
+ info.imsx_messageIdentifier @message_identifier || AJIMS::LTI::generate_identifier
138
+ info.imsx_statusInfo do |status|
139
+ status.imsx_codeMajor @code_major
140
+ status.imsx_severity @severity
141
+ status.imsx_description @description
142
+ status.imsx_messageRefIdentifier @message_ref_identifier
143
+ status.imsx_operationRefIdentifier @operation
144
+ end
145
+ end
146
+ end #/header
147
+ env.imsx_POXBody do |body|
148
+ unless unsupported?
149
+ body.tag!(@operation + 'Response') do |request|
150
+ if @operation == OutcomeRequest::READ_REQUEST
151
+ request.result do |res|
152
+ res.resultScore do |res_score|
153
+ res_score.language "en" # 'en' represents the format of the number
154
+ res_score.textString @score.to_s
155
+ end
156
+ end #/result
157
+ end
158
+ end #/operationResponse
159
+ end
160
+ end #/body
161
+ end
162
+ end
163
+
164
+ end
165
+ end
@@ -0,0 +1,50 @@
1
+ module AJIMS::LTI
2
+ # A mixin for OAuth request validation
3
+ module RequestValidator
4
+
5
+ attr_reader :oauth_signature_validator
6
+
7
+ # Validates and OAuth request using the OAuth Gem - https://github.com/oauth/oauth-ruby
8
+ #
9
+ # To validate the OAuth signatures you need to require the appropriate
10
+ # request proxy for your application. For example:
11
+ #
12
+ # # For a sinatra app:
13
+ # require 'oauth/request_proxy/rack_request'
14
+ #
15
+ # # For a rails app:
16
+ # require 'oauth/request_proxy/action_controller_request'
17
+ # @return [Bool] Whether the request was valid
18
+ def valid_request?(request, handle_error=true)
19
+ begin
20
+ @oauth_signature_validator = OAuth::Signature.build(request, :consumer_secret => @consumer_secret)
21
+ @oauth_signature_validator.verify() or raise OAuth::Unauthorized
22
+ true
23
+ rescue OAuth::Signature::UnknownSignatureMethod, OAuth::Unauthorized
24
+ if handle_error
25
+ false
26
+ else
27
+ raise $!
28
+ end
29
+ end
30
+ end
31
+
32
+ # Check whether the OAuth-signed request is valid and throw error if not
33
+ #
34
+ # @return [Bool] Whether the request was valid
35
+ def valid_request!(request)
36
+ valid_request?(request, false)
37
+ end
38
+
39
+ # convenience method for getting the oauth nonce from the request
40
+ def request_oauth_nonce
41
+ @oauth_signature_validator && @oauth_signature_validator.request.oauth_nonce
42
+ end
43
+
44
+ # convenience method for getting the oauth timestamp from the request
45
+ def request_oauth_timestamp
46
+ @oauth_signature_validator && @oauth_signature_validator.request.oauth_timestamp
47
+ end
48
+
49
+ end
50
+ end
@@ -0,0 +1,225 @@
1
+ module AJIMS::LTI
2
+ # Class used to represent an LTI configuration
3
+ #
4
+ # It can create and read the Common Cartridge XML representation of LTI links
5
+ # as described here: http://www.imsglobal.org/LTI/v1p1pd/ltiIMGv1p1pd.html#_Toc309649689
6
+ #
7
+ # == Usage
8
+ # To generate an XML configuration:
9
+ #
10
+ # # Create a config object and set some options
11
+ # tc = IMS::LTI::ToolConfig.new(:title => "Example Sinatra Tool Provider", :launch_url => url)
12
+ # tc.description = "This example LTI Tool Provider supports LIS Outcome pass-back."
13
+ #
14
+ # # generate the XML
15
+ # tc.to_xml
16
+ #
17
+ # Or to create a config object from an XML String:
18
+ #
19
+ # tc = IMS::LTI::ToolConfig.create_from_xml(xml)
20
+ class ToolConfig
21
+ attr_reader :custom_params, :extensions
22
+
23
+ attr_accessor :title, :description, :launch_url, :secure_launch_url,
24
+ :icon, :secure_icon, :cartridge_bundle, :cartridge_icon,
25
+ :vendor_code, :vendor_name, :vendor_description, :vendor_url,
26
+ :vendor_contact_email, :vendor_contact_name
27
+
28
+ # Create a new ToolConfig with the given options
29
+ #
30
+ # @param opts [Hash] The initial options for the ToolConfig
31
+ def initialize(opts={})
32
+ @custom_params = opts.delete("custom_params") || {}
33
+ @extensions = opts.delete("extensions") || {}
34
+
35
+ opts.each_pair do |key, val|
36
+ self.send("#{key}=", val) if self.respond_to?("#{key}=")
37
+ end
38
+ end
39
+
40
+ # Create a ToolConfig from the given XML
41
+ #
42
+ # @param xml [String]
43
+ def self.create_from_xml(xml)
44
+ tc = ToolConfig.new
45
+ tc.process_xml(xml)
46
+
47
+ tc
48
+ end
49
+
50
+ def set_custom_param(key, val)
51
+ @custom_params[key] = val
52
+ end
53
+
54
+ def get_custom_param(key)
55
+ @custom_params[key]
56
+ end
57
+
58
+ # Set the extension parameters for a specific vendor
59
+ #
60
+ # @param ext_key [String] The identifier for the vendor-specific parameters
61
+ # @param ext_params [Hash] The parameters, this is allowed to be two-levels deep
62
+ def set_ext_params(ext_key, ext_params)
63
+ raise ArgumentError unless ext_params.is_a?(Hash)
64
+ @extensions[ext_key] = ext_params
65
+ end
66
+
67
+ def get_ext_params(ext_key)
68
+ @extensions[ext_key]
69
+ end
70
+
71
+ def set_ext_param(ext_key, param_key, val)
72
+ @extensions[ext_key] ||= {}
73
+ @extensions[ext_key][param_key] = val
74
+ end
75
+
76
+ def get_ext_param(ext_key, param_key)
77
+ @extensions[ext_key] && @extensions[ext_key][param_key]
78
+ end
79
+
80
+ # Namespaces used for parsing configuration XML
81
+ LTI_NAMESPACES = {
82
+ "xmlns" => 'http://www.imsglobal.org/xsd/imslticc_v1p0',
83
+ "blti" => 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0',
84
+ "lticm" => 'http://www.imsglobal.org/xsd/imslticm_v1p0',
85
+ "lticp" => 'http://www.imsglobal.org/xsd/imslticp_v1p0',
86
+ }
87
+
88
+ # Parse tool configuration data out of the Common Cartridge LTI link XML
89
+ def process_xml(xml)
90
+ doc = REXML::Document.new xml
91
+ if root = REXML::XPath.first(doc, 'xmlns:cartridge_basiclti_link')
92
+ @title = get_node_text(root, 'blti:title')
93
+ @description = get_node_text(root, 'blti:description')
94
+ @launch_url = get_node_text(root, 'blti:launch_url')
95
+ @secure_launch_url = get_node_text(root, 'blti:secure_launch_url')
96
+ @icon = get_node_text(root, 'blti:icon')
97
+ @secure_icon = get_node_text(root, 'blti:secure_icon')
98
+ @cartridge_bundle = get_node_att(root, 'xmlns:cartridge_bundle', 'identifierref')
99
+ @cartridge_icon = get_node_att(root, 'xmlns:cartridge_icon', 'identifierref')
100
+
101
+ if vendor = REXML::XPath.first(root, 'blti:vendor')
102
+ @vendor_code = get_node_text(vendor, 'lticp:code')
103
+ @vendor_description = get_node_text(vendor, 'lticp:description')
104
+ @vendor_name = get_node_text(vendor, 'lticp:name')
105
+ @vendor_url = get_node_text(vendor, 'lticp:url')
106
+ @vendor_contact_email = get_node_text(vendor, '//lticp:contact/lticp:email')
107
+ @vendor_contact_name = get_node_text(vendor, '//lticp:contact/lticp:name')
108
+ end
109
+
110
+ if custom = REXML::XPath.first(root, 'blti:custom', LTI_NAMESPACES)
111
+ set_properties(@custom_params, custom)
112
+ end
113
+
114
+ REXML::XPath.each(root, 'blti:extensions', LTI_NAMESPACES) do |vendor_ext_node|
115
+ platform = vendor_ext_node.attributes['platform']
116
+ properties = {}
117
+ set_properties(properties, vendor_ext_node)
118
+ REXML::XPath.each(vendor_ext_node, 'lticm:options', LTI_NAMESPACES) do |options_node|
119
+ opt_name = options_node.attributes['name']
120
+ options = {}
121
+ set_properties(options, options_node)
122
+ properties[opt_name] = options
123
+ end
124
+
125
+ self.set_ext_params(platform, properties)
126
+ end
127
+
128
+ end
129
+ end
130
+
131
+ # Generate XML from the current settings
132
+ def to_xml(opts = {})
133
+ raise AJIMS::LTI::InvalidLTIConfigError, "A launch url is required for an LTI configuration." unless self.launch_url || self.secure_launch_url
134
+
135
+ builder = Builder::XmlMarkup.new(:indent => opts[:indent] || 0)
136
+ builder.instruct!
137
+ builder.cartridge_basiclti_link("xmlns" => "http://www.imsglobal.org/xsd/imslticc_v1p0",
138
+ "xmlns:blti" => 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0',
139
+ "xmlns:lticm" => 'http://www.imsglobal.org/xsd/imslticm_v1p0',
140
+ "xmlns:lticp" => 'http://www.imsglobal.org/xsd/imslticp_v1p0',
141
+ "xmlns:xsi" => "http://www.w3.org/2001/XMLSchema-instance",
142
+ "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_v1p0p1.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"
143
+ ) do |blti_node|
144
+
145
+ %w{title description launch_url secure_launch_url icon secure_icon}.each do |key|
146
+ blti_node.blti key.to_sym, self.send(key) if self.send(key)
147
+ end
148
+
149
+ vendor_keys = %w{name code description url}
150
+ if vendor_keys.any?{|k|self.send("vendor_#{k}")} || vendor_contact_email
151
+ blti_node.blti :vendor do |v_node|
152
+ vendor_keys.each do |key|
153
+ v_node.lticp key.to_sym, self.send("vendor_#{key}") if self.send("vendor_#{key}")
154
+ end
155
+ if vendor_contact_email
156
+ v_node.lticp :contact do |c_node|
157
+ c_node.lticp :name, vendor_contact_name
158
+ c_node.lticp :email, vendor_contact_email
159
+ end
160
+ end
161
+ end
162
+ end
163
+
164
+ if !@custom_params.empty?
165
+ blti_node.tag!("blti:custom") do |custom_node|
166
+ @custom_params.keys.sort.each do |key|
167
+ val = @custom_params[key]
168
+ custom_node.lticm :property, val, 'name' => key
169
+ end
170
+ end
171
+ end
172
+
173
+ if !@extensions.empty?
174
+ @extensions.keys.sort.each do |ext_platform|
175
+ ext_params = @extensions[ext_platform]
176
+ blti_node.blti(:extensions, :platform => ext_platform) do |ext_node|
177
+ ext_params.keys.sort.each do |key|
178
+ val = ext_params[key]
179
+ if val.is_a?(Hash)
180
+ ext_node.lticm(:options, :name => key) do |type_node|
181
+ val.keys.sort.each do |p_key|
182
+ p_val = val[p_key]
183
+ type_node.lticm :property, p_val, 'name' => p_key
184
+ end
185
+ end
186
+ else
187
+ ext_node.lticm :property, val, 'name' => key
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+
194
+ blti_node.cartridge_bundle(:identifierref => @cartridge_bundle) if @cartridge_bundle
195
+ blti_node.cartridge_icon(:identifierref => @cartridge_icon) if @cartridge_icon
196
+
197
+ end
198
+ end
199
+
200
+ private
201
+
202
+ def get_node_text(node, path)
203
+ if val = REXML::XPath.first(node, path, LTI_NAMESPACES)
204
+ val.text
205
+ else
206
+ nil
207
+ end
208
+ end
209
+
210
+ def get_node_att(node, path, att)
211
+ if val = REXML::XPath.first(node, path, LTI_NAMESPACES)
212
+ val.attributes[att]
213
+ else
214
+ nil
215
+ end
216
+ end
217
+
218
+ def set_properties(hash, node)
219
+ REXML::XPath.each(node, 'lticm:property', LTI_NAMESPACES) do |prop|
220
+ hash[prop.attributes['name']] = prop.text
221
+ end
222
+ end
223
+
224
+ end
225
+ end
@@ -0,0 +1,95 @@
1
+ module AJIMS::LTI
2
+ # Class for implementing an LTI Tool Consumer
3
+ class ToolConsumer
4
+ include AJIMS::LTI::Extensions::Base
5
+ include AJIMS::LTI::LaunchParams
6
+ include AJIMS::LTI::RequestValidator
7
+
8
+ attr_accessor :consumer_key, :consumer_secret, :launch_url, :timestamp, :nonce
9
+
10
+ # Create a new ToolConsumer
11
+ #
12
+ # @param consumer_key [String] The OAuth consumer key
13
+ # @param consumer_secret [String] The OAuth consumer secret
14
+ # @param params [Hash] Set the launch parameters as described in LaunchParams
15
+ def initialize(consumer_key, consumer_secret, params={})
16
+ @consumer_key = consumer_key
17
+ @consumer_secret = consumer_secret
18
+ @custom_params = {}
19
+ @ext_params = {}
20
+ @non_spec_params = {}
21
+ @launch_url = params['launch_url']
22
+ process_params(params)
23
+ end
24
+
25
+ def process_post_request(post_request)
26
+ request = extend_outcome_request(OutcomeRequest.new)
27
+ request.process_post_request(post_request)
28
+ end
29
+
30
+ # Set launch data from a ToolConfig
31
+ #
32
+ # @param config [ToolConfig]
33
+ def set_config(config)
34
+ @launch_url ||= config.secure_launch_url
35
+ @launch_url ||= config.launch_url
36
+ # any parameters already set will take priority
37
+ @custom_params = config.custom_params.merge(@custom_params)
38
+ end
39
+
40
+ # Check if the required parameters for a tool launch are set
41
+ def has_required_params?
42
+ @consumer_key && @consumer_secret && @resource_link_id && @launch_url
43
+ end
44
+
45
+ # Generate the launch data including the necessary OAuth information
46
+ #
47
+ #
48
+ def generate_launch_data
49
+ raise AJIMS::LTI::InvalidLTIConfigError, "Not all required params set for tool launch" unless has_required_params?
50
+
51
+ params = self.to_params
52
+ params['lti_version'] ||= 'LTI-1p0'
53
+ params['lti_message_type'] ||= 'basic-lti-launch-request'
54
+ uri = URI.parse(@launch_url)
55
+
56
+ if uri.port == uri.default_port
57
+ host = uri.host
58
+ else
59
+ host = "#{uri.host}:#{uri.port}"
60
+ end
61
+
62
+ consumer = OAuth::Consumer.new(@consumer_key, @consumer_secret, {
63
+ :site => "#{uri.scheme}://#{host}",
64
+ :signature_method => "HMAC-SHA1"
65
+ })
66
+
67
+ path = uri.path
68
+ path = '/' if path.empty?
69
+ if uri.query && uri.query != ''
70
+ CGI.parse(uri.query).each do |query_key, query_values|
71
+ unless params[query_key]
72
+ params[query_key] = query_values.first
73
+ end
74
+ end
75
+ end
76
+ options = {
77
+ :scheme => 'body',
78
+ :timestamp => @timestamp,
79
+ :nonce => @nonce
80
+ }
81
+ request = consumer.create_signed_request(:post, path, nil, options, params)
82
+
83
+ # the request is made by a html form in the user's browser, so we
84
+ # want to revert the escapage and return the hash of post parameters ready
85
+ # for embedding in a html view
86
+ hash = {}
87
+ request.body.split(/&/).each do |param|
88
+ key, val = param.split(/=/).map { |v| CGI.unescape(v) }
89
+ hash[key] = val
90
+ end
91
+ hash
92
+ end
93
+
94
+ end
95
+ end