aj-ims-lti 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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