lms-api 1.0.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: c81240c233496b4c395e79b88690cf87fc5229bf
4
+ data.tar.gz: b9ec5abbdcde51b4776dfc16fc0ecb45e9cae2a1
5
+ SHA512:
6
+ metadata.gz: 48566b6c283242f347cd706a3dd2c78766ef12d7cbce7ecd30fbbf8abbac1ceb2a774c5a70d44c93d6ae0748bf912e4dc07b8ad622db511a5508563c4f6151f5
7
+ data.tar.gz: 3ef819fda17bc8bd1fd67b001367abbcc2297573e310ea7421f8a61f6e775b4ffd4c16a9e4368a088381067b43e394a59b8db45cb00e33dd08f753dadaba2a40
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2016 Jamis Buck
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # LMS::API
2
+
3
+ This project describes a wrapper around LMS REST APIs.
4
+
5
+
6
+ ## Installation
7
+
8
+ To install, add `lms-api` to your Gemfile:
9
+
10
+ ```ruby
11
+ gem "lms-api"
12
+ ```
13
+
14
+
15
+ ## Configuration
16
+
17
+ Your app must tell the gem which model is used to represent the
18
+ authentication state. For instance, if you're using ActiveRecord, you
19
+ might have an `Authentication` model, which encapsulates a temporary
20
+ API token.
21
+
22
+ ```ruby
23
+ class Authentication < ActiveRecord::Base
24
+ # token: string
25
+ end
26
+ ```
27
+
28
+ Then, you tell the gem about this model:
29
+
30
+ ```ruby
31
+ LMS::API.auth_state_model = Authentication
32
+ ```
33
+
34
+ This allows the gem to transparently refresh the token when the token
35
+ expires, and do so in a way that respects multiple processes all trying
36
+ to do so in parallel.
37
+
38
+
39
+ ## Usage
40
+
41
+ To use the API wrapper, instantiate a `LMS::API` instance with the
42
+ url of the LMS instance you want to communicate with, as well as the
43
+ current authentication object, and (optionally) a hash of options to use
44
+ when refreshing the API token.
45
+
46
+ ```ruby
47
+ auth = Authentication.first # or however you are storing global auth state
48
+ api = LMS::API.new("http://your.canvas.instance", auth,
49
+ client_id: "...",
50
+ client_secret: "..."
51
+ redirect_uri: "..."
52
+ refresh_token: "...")
53
+ ```
54
+
55
+ You can get the URL for a given LMS interface via the `::lms_url`
56
+ class method:
57
+
58
+ ```ruby
59
+ params = {
60
+ id: id,
61
+ course_id: course_id,
62
+ controller: "foo",
63
+ account_id: 1,
64
+ all_dates: true,
65
+ other_param: "foobar"}
66
+
67
+ url = LMS::API.lms_url("GET_SINGLE_ASSIGNMENT", params)
68
+ ```
69
+
70
+ Once you have the URL, you can send the request by using `api_*_request`
71
+ methods:
72
+
73
+ * `api_get_request(url, headers={})`
74
+ * `api_post_request(url, payload, headers={})`
75
+ * `api_delete_request(url, headers={})`
76
+ * `api_get_all_request(url, headers={})`
77
+ * `api_get_blocks_request(url, headers={}, &block)`
78
+
79
+ The last two are convenience methods for fetching multiple pages of data.
80
+ The `api_get_all_request` method returns all rows in a single array. The
81
+ `api_get_blocks_request` method yields each "chunk" of data to the block.
data/Rakefile ADDED
@@ -0,0 +1,30 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'Canvas'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.rdoc')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ load './lib/tasks/lms_api.rake'
18
+
19
+
20
+
21
+
22
+ Bundler::GemHelper.install_tasks
23
+
24
+ begin
25
+ require 'rspec/core/rake_task'
26
+ RSpec::Core::RakeTask.new(:spec)
27
+
28
+ task :default => :spec
29
+ rescue LoadError
30
+ end
data/lib/lms/api.rb ADDED
@@ -0,0 +1,340 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext/object/blank'
3
+ require 'active_support/core_ext/object/to_query'
4
+ require 'active_support/core_ext/hash/keys'
5
+
6
+ require 'lms/urls'
7
+
8
+ module LMS
9
+ class API
10
+
11
+ # a model that encapsulates authentication state. By default, it
12
+ # is nil, but it may be set to any object that responds to:
13
+ # - #transaction { .. }
14
+ # - #lock(true) -> returns self
15
+ # - #find(id) -> returns an authentication object (see
16
+ # the `authentication` parameter of #initialize, below).
17
+ class <<self
18
+ attr_accessor :auth_state_model
19
+ end
20
+
21
+ # instance accessor, for convenience
22
+ def auth_state_model
23
+ self.class.auth_state_model
24
+ end
25
+
26
+ # callback must accept a single parameter (the API object itself)
27
+ # and return the new authentication object.
28
+ def self.on_auth(callback=nil, &block)
29
+ @@on_auth = callback || block
30
+ end
31
+
32
+ # set up a default auth callback. It assumes that #auth_state_model
33
+ # is set. If #auth_state_model will not be set, the client app must
34
+ # define a custom on_auth callback.
35
+ self.on_auth do |api|
36
+ api.lock do |record|
37
+ if record.token == api.authentication.token
38
+ record.update token: api.refresh_token
39
+ end
40
+ end
41
+ end
42
+
43
+
44
+ attr_reader :authentication
45
+
46
+ # The authentication parameter must be either a string (indicating
47
+ # a token), or an object that responds to:
48
+ # - #id
49
+ # - #token
50
+ # - #update(hash) -- which should update #token with hash[:token]:noh
51
+ def initialize(lms_uri, authentication, refresh_token_options=nil)
52
+ @per_page = 100
53
+ @lms_uri = lms_uri
54
+ @refresh_token_options = refresh_token_options
55
+ if authentication.is_a?(String)
56
+ @authentication = OpenStruct.new(token: authentication)
57
+ else
58
+ @authentication = authentication
59
+ end
60
+
61
+ if refresh_token_options.present?
62
+ required_options = [:client_id, :client_secret, :redirect_uri, :refresh_token]
63
+ extra_options = @refresh_token_options.keys - required_options
64
+ raise InvalidRefreshOptionsException, "Invalid option(s) provided: #{extra_options.join(', ')}" unless extra_options.length == 0
65
+ missing_options = required_options - @refresh_token_options.keys
66
+ raise InvalidRefreshOptionsException, "Missing required option(s): #{missing_options.join(', ')}" unless missing_options.length == 0
67
+ end
68
+ end
69
+
70
+ # Obtains a lock (via the API.auth_state_model interface) and
71
+ # yields an authentication object corresponding to
72
+ # self.authentication.id. The object is returned when the block
73
+ # finishes.
74
+ def lock
75
+ auth_state_model.transaction do
76
+ record = auth_state_model.
77
+ lock(true).
78
+ find(authentication.id)
79
+
80
+ yield record
81
+
82
+ record
83
+ end
84
+ end
85
+
86
+ def headers(additional_headers = {})
87
+ {
88
+ "Authorization" => "Bearer #{@authentication.token}",
89
+ "User-Agent" => "LMS-API Ruby"
90
+ }.merge(additional_headers)
91
+ end
92
+
93
+ def full_url(api_url, use_api_prefix=true)
94
+ if api_url[0...4] == 'http'
95
+ api_url
96
+ else
97
+ if use_api_prefix
98
+ "#{@lms_uri}/api/v1/#{api_url}"
99
+ else
100
+ "#{@lms_uri}/#{api_url}"
101
+ end
102
+ end
103
+ end
104
+
105
+ def api_put_request(api_url, payload, additional_headers = {})
106
+ url = full_url(api_url)
107
+ refreshably do
108
+ HTTParty.put(url, headers: headers(additional_headers), body: payload)
109
+ end
110
+ end
111
+
112
+ def api_post_request(api_url, payload, additional_headers = {})
113
+ url = full_url(api_url)
114
+ refreshably do
115
+ HTTParty.post(url, headers: headers(additional_headers), body: payload)
116
+ end
117
+ end
118
+
119
+ def api_get_request(api_url, additional_headers = {})
120
+ url = full_url(api_url)
121
+ refreshably do
122
+ HTTParty.get(url, headers: headers(additional_headers))
123
+ end
124
+ end
125
+
126
+ def api_delete_request(api_url, additional_headers = {})
127
+ url = full_url(api_url)
128
+ refreshably do
129
+ HTTParty.delete(url, headers: headers(additional_headers))
130
+ end
131
+ end
132
+
133
+ def api_get_all_request(api_url, additional_headers = {})
134
+ [].tap do |results|
135
+ api_get_blocks_request(api_url, additional_headers) do |result|
136
+ results.concat(result)
137
+ end
138
+ end
139
+ end
140
+
141
+ def api_get_blocks_request(api_url, additional_headers = {})
142
+ connector = api_url.include?('?') ? '&' : '?'
143
+ next_url = "#{api_url}#{connector}per_page=#{@per_page}"
144
+ while next_url do
145
+ result = api_get_request(next_url, additional_headers)
146
+ yield result
147
+ next_url = get_next_url(result.headers['link'])
148
+ end
149
+ end
150
+
151
+ def refreshably
152
+ result = yield
153
+ check_result(result)
154
+ rescue LMS::API::RefreshTokenRequired => ex
155
+ raise ex if @refresh_token_options.blank?
156
+ @authentication = @@on_auth.call(self)
157
+ retry
158
+ end
159
+
160
+ def refresh_token
161
+ payload = {
162
+ grant_type: 'refresh_token'
163
+ }.merge(@refresh_token_options)
164
+ url = full_url("login/oauth2/token", false)
165
+ result = HTTParty.post(url, headers: headers, body: payload)
166
+ raise LMS::API::RefreshTokenFailedException, api_error(result) unless [200, 201].include?(result.response.code.to_i)
167
+ result['access_token']
168
+ end
169
+
170
+ def check_result(result)
171
+
172
+ code = result.response.code.to_i
173
+
174
+ return result if [200, 201].include?(code)
175
+
176
+ if code == 401 && result.headers['www-authenticate'] == 'Bearer realm="canvas-lms"'
177
+ raise LMS::API::RefreshTokenRequired
178
+ end
179
+
180
+ raise LMS::API::InvalidAPIRequestException, api_error(result)
181
+ end
182
+
183
+ def api_error(result)
184
+ error = "Status: #{result.headers['status']} \n"
185
+ error << "Http Response: #{result.response.code} \n"
186
+ error << "Error: #{result['errors'] || result.response.message} \n"
187
+ end
188
+
189
+ def get_next_url(link)
190
+ return nil if link.blank?
191
+ if url = link.split(',').find{|l| l.split(";")[1].strip == 'rel="next"' }
192
+ url.split(';')[0].gsub(/[\<\>\s]/, "")
193
+ end
194
+ end
195
+
196
+ def proxy(type, params, payload = nil, get_all = false)
197
+
198
+ additional_headers = {
199
+ "Content-Type" => "application/json"
200
+ }
201
+
202
+ method = LMS::URLs[type][:method]
203
+ url = LMS::API.lms_url(type, params, payload)
204
+
205
+ case method
206
+ when 'GET'
207
+ if block_given?
208
+ api_get_blocks_request(url, additional_headers) do |result|
209
+ yield result
210
+ end
211
+ elsif get_all
212
+ api_get_all_request(url, additional_headers)
213
+ else
214
+ api_get_request(url, additional_headers)
215
+ end
216
+ when 'POST'
217
+ api_post_request(url, payload, additional_headers)
218
+ when 'PUT'
219
+ api_put_request(url, payload, additional_headers)
220
+ when 'DELETE'
221
+ api_delete_request(url, additional_headers)
222
+ else
223
+ raise LMS::API::InvalidAPIMethodRequestException "Invalid method type: #{method}"
224
+ end
225
+
226
+ rescue LMS::API::InvalidAPIRequestException => ex
227
+ error = ex.to_s
228
+ error << "API Request Url: #{url} \n"
229
+ error << "API Request Params: #{params} \n"
230
+ error << "API Request Payload: #{payload} \n"
231
+ new_ex = LMS::API::InvalidAPIRequestFailedException.new(error)
232
+ new_ex.set_backtrace(ex.backtrace)
233
+ raise new_ex
234
+
235
+ end
236
+
237
+ # Ignore required params for specific calls. For example, the external tool calls
238
+ # have required params "name, privacy_level, consumer_key, shared_secret". However, those
239
+ # params are not required if the call specifies config_type: "by_xml".
240
+ def self.ignore_required(type)
241
+ [
242
+ "CREATE_EXTERNAL_TOOL_COURSES",
243
+ "CREATE_EXTERNAL_TOOL_ACCOUNTS"
244
+ ].include?(type)
245
+ end
246
+
247
+ def self.lms_url(type, params, payload = nil)
248
+ endpoint = LMS::URLs[type]
249
+ parameters = endpoint[:parameters]
250
+
251
+ # Make sure all required parameters are present
252
+ missing = []
253
+ if !self.ignore_required(type)
254
+ parameters.find_all{|p| p["required"]}.map{|p| p["name"]}.each do |p|
255
+ if p.include?("[") && p.include?("]")
256
+ parts = p.split('[')
257
+ parent = parts[0].to_sym
258
+ child = parts[1].gsub("]", "").to_sym
259
+ missing << p unless (params[parent].present? && params[parent][child].present?) ||
260
+ (payload.present? && payload[parent].present? && payload[parent][child].present?)
261
+ else
262
+ missing << p unless params[p.to_sym].present? || (payload.present? && !payload.is_a?(String) && payload[p.to_sym].present?)
263
+ end
264
+ end
265
+ end
266
+
267
+ if missing.length > 0
268
+ raise LMS::API::MissingRequiredParameterException, "Missing required parameter(s): #{missing.join(', ')}"
269
+ end
270
+
271
+ # Generate the uri. Only allow path parameters
272
+ uri_proc = endpoint[:uri]
273
+ path_parameters = parameters.find_all{|p| p["paramType"] == "path"}.map{|p| p["name"].to_sym}
274
+ args = params.slice(*path_parameters).symbolize_keys
275
+ uri = args.blank? ? uri_proc.call : uri_proc.call(**args)
276
+
277
+ # Generate the query string
278
+ query_parameters = parameters.find_all{|p| p["paramType"] == "query"}.map{|p| p["name"].to_sym}
279
+
280
+ # always allow paging parameters
281
+ query_parameters << :per_page
282
+ query_parameters << :page
283
+
284
+ allowed_params = params.slice(*query_parameters)
285
+
286
+ if allowed_params.present?
287
+ "#{uri}?#{allowed_params.to_query}"
288
+ else
289
+ uri
290
+ end
291
+
292
+ end
293
+
294
+
295
+ #
296
+ # Helper methods
297
+ #
298
+
299
+ # Get all accounts including sub accounts
300
+ def all_accounts
301
+ all = []
302
+ self.proxy("LIST_ACCOUNTS", {}, nil, true).each do |account|
303
+ all << account
304
+ sub_accounts = self.proxy("GET_SUB_ACCOUNTS_OF_ACCOUNT", {account_id: account['id']}, nil, true)
305
+ all = all.concat(sub_accounts)
306
+ end
307
+ all
308
+ end
309
+
310
+
311
+ #
312
+ # Exceptions
313
+ #
314
+
315
+ class Exception < RuntimeError
316
+ end
317
+
318
+ class RefreshTokenRequired < Exception
319
+ end
320
+
321
+ class InvalidRefreshOptionsException < Exception
322
+ end
323
+
324
+ class RefreshTokenFailedException < Exception
325
+ end
326
+
327
+ class InvalidAPIRequestException < Exception
328
+ end
329
+
330
+ class InvalidAPIRequestFailedException < Exception
331
+ end
332
+
333
+ class InvalidAPIMethodRequestException < Exception
334
+ end
335
+
336
+ class MissingRequiredParameterException < Exception
337
+ end
338
+
339
+ end
340
+ end