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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +81 -0
- data/Rakefile +30 -0
- data/lib/lms/api.rb +340 -0
- data/lib/lms/urls.rb +665 -0
- data/lib/lms/version.rb +3 -0
- data/lib/lms.rb +4 -0
- data/lib/tasks/lms_api/constant.erb +10 -0
- data/lib/tasks/lms_api/constants.erb +4 -0
- data/lib/tasks/lms_api/graphql_model.erb +7 -0
- data/lib/tasks/lms_api/graphql_mutation.erb +0 -0
- data/lib/tasks/lms_api/graphql_mutations.erb +6 -0
- data/lib/tasks/lms_api/graphql_queries.erb +7 -0
- data/lib/tasks/lms_api/graphql_query.erb +7 -0
- data/lib/tasks/lms_api/graphql_types.erb +64 -0
- data/lib/tasks/lms_api/js_url.erb +1 -0
- data/lib/tasks/lms_api/js_urls.erb +3 -0
- data/lib/tasks/lms_api/rb_url.erb +1 -0
- data/lib/tasks/lms_api/rb_urls.erb +5 -0
- data/lib/tasks/lms_api.rake +265 -0
- metadata +134 -0
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
|