lms-api 1.0.0

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