lms-api 1.17.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 +84 -0
- data/Rakefile +27 -0
- data/lib/canvas_api.rb +4 -0
- data/lib/canvas_api/builder.rb +121 -0
- data/lib/canvas_api/js_graphql_helpers.rb +98 -0
- data/lib/canvas_api/js_helpers.rb +58 -0
- data/lib/canvas_api/rb_graphql_helpers.rb +241 -0
- data/lib/canvas_api/render.rb +74 -0
- data/lib/canvas_api/ruby_helpers.rb +9 -0
- data/lib/canvas_api/templates/constant.erb +10 -0
- data/lib/canvas_api/templates/constants.erb +4 -0
- data/lib/canvas_api/templates/course_id_required.erb +1 -0
- data/lib/canvas_api/templates/course_ids_required.erb +5 -0
- data/lib/canvas_api/templates/ex_action.erb +1 -0
- data/lib/canvas_api/templates/ex_default_action.erb +1 -0
- data/lib/canvas_api/templates/ex_url.erb +18 -0
- data/lib/canvas_api/templates/ex_urls.erb +3 -0
- data/lib/canvas_api/templates/js_graphql_model.erb +9 -0
- data/lib/canvas_api/templates/js_graphql_mutation.erb +0 -0
- data/lib/canvas_api/templates/js_graphql_mutations.erb +6 -0
- data/lib/canvas_api/templates/js_graphql_queries.erb +7 -0
- data/lib/canvas_api/templates/js_graphql_query.erb +7 -0
- data/lib/canvas_api/templates/js_graphql_types.erb +100 -0
- data/lib/canvas_api/templates/js_url.erb +1 -0
- data/lib/canvas_api/templates/js_urls.erb +3 -0
- data/lib/canvas_api/templates/rb_forward_declarations.erb +14 -0
- data/lib/canvas_api/templates/rb_graphql_field.erb +3 -0
- data/lib/canvas_api/templates/rb_graphql_input_type.erb +14 -0
- data/lib/canvas_api/templates/rb_graphql_mutation.erb +20 -0
- data/lib/canvas_api/templates/rb_graphql_mutation_include.erb +1 -0
- data/lib/canvas_api/templates/rb_graphql_mutations.erb +15 -0
- data/lib/canvas_api/templates/rb_graphql_resolver.erb +27 -0
- data/lib/canvas_api/templates/rb_graphql_root_query.erb +14 -0
- data/lib/canvas_api/templates/rb_graphql_type.erb +14 -0
- data/lib/canvas_api/templates/rb_url.erb +1 -0
- data/lib/canvas_api/templates/rb_urls.erb +5 -0
- data/lib/lms/canvas.rb +447 -0
- data/lib/lms/canvas_urls.rb +812 -0
- data/lib/lms/course_ids_required.rb +309 -0
- data/lib/lms/helper_urls.rb +8 -0
- data/lib/lms/utils.rb +6 -0
- data/lib/lms/version.rb +3 -0
- data/lib/lms_api.rb +4 -0
- data/lib/tasks/canvas_api.rake +23 -0
- metadata +175 -0
@@ -0,0 +1,74 @@
|
|
1
|
+
require "canvas_api/js_graphql_helpers"
|
2
|
+
require "canvas_api/js_helpers"
|
3
|
+
require "canvas_api/ruby_helpers"
|
4
|
+
require "canvas_api/rb_graphql_helpers"
|
5
|
+
require "byebug"
|
6
|
+
module CanvasApi
|
7
|
+
|
8
|
+
class Render
|
9
|
+
include CanvasApi::GraphQLHelpers
|
10
|
+
include CanvasApi::JsHelpers
|
11
|
+
include CanvasApi::RubyHelpers
|
12
|
+
attr_accessor :template, :description, :resource, :api_url, :operation,
|
13
|
+
:args, :method, :api, :name, :resource_name, :resource_api,
|
14
|
+
:nickname, :notes, :content, :summary, :model, :model_name
|
15
|
+
|
16
|
+
def initialize(template, api, resource, resource_api, operation, parameters, content, model)
|
17
|
+
@template = File.read(File.expand_path(template, __dir__))
|
18
|
+
if api
|
19
|
+
@api = api
|
20
|
+
@name = @api["path"].gsub("/", "").gsub(".json", "")
|
21
|
+
@description = @api["description"]
|
22
|
+
end
|
23
|
+
if resource
|
24
|
+
@resource = resource
|
25
|
+
@resource_name = resource["resourcePath"].gsub("/", "")
|
26
|
+
end
|
27
|
+
if resource_api
|
28
|
+
@resource_api = resource_api
|
29
|
+
@api_url = resource_api["path"].gsub("/v1/", "")
|
30
|
+
@args = args(@api_url)
|
31
|
+
end
|
32
|
+
if operation
|
33
|
+
nickname = operation["nickname"]
|
34
|
+
nickname = "#{@name}_#{nickname}" if [
|
35
|
+
"upload_file",
|
36
|
+
"query_by_course",
|
37
|
+
"preview_processed_html",
|
38
|
+
"create_peer_review_courses",
|
39
|
+
"create_peer_review_sections",
|
40
|
+
"set_extensions_for_student_quiz_submissions"
|
41
|
+
].include?(nickname)
|
42
|
+
|
43
|
+
@method = operation["method"]
|
44
|
+
@operation = operation
|
45
|
+
@nickname = nickname
|
46
|
+
@notes = operation["notes"].gsub("\n", "\n// ")
|
47
|
+
@summary = operation["summary"]
|
48
|
+
end
|
49
|
+
if parameters
|
50
|
+
@parameters = parameters.map { |p| p.delete("description"); p }
|
51
|
+
end
|
52
|
+
@content = content
|
53
|
+
@model = model
|
54
|
+
end
|
55
|
+
|
56
|
+
def args(api_url)
|
57
|
+
api_url.split("/").map do |part|
|
58
|
+
if part[0] == "{"
|
59
|
+
part.gsub(/[\{\}]/, "")
|
60
|
+
end
|
61
|
+
end.compact
|
62
|
+
end
|
63
|
+
|
64
|
+
def render
|
65
|
+
ERB.new(@template, nil, "-").result(binding).strip
|
66
|
+
end
|
67
|
+
|
68
|
+
def save(file)
|
69
|
+
File.write(file, render)
|
70
|
+
end
|
71
|
+
|
72
|
+
end
|
73
|
+
|
74
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
<% parameters_doc = parameters_doc(operation, @method.downcase) -%>
|
2
|
+
// <%=@summary%>
|
3
|
+
// <%=@notes%>
|
4
|
+
//
|
5
|
+
// API Docs: https://canvas.instructure.com/doc/api/<%=@resource_name%>.html
|
6
|
+
// API Url: <%=@api_url%>
|
7
|
+
//
|
8
|
+
// Example:<%=parameters_doc%>
|
9
|
+
// return canvasRequest(<%=@nickname%>, {<%=@args.present? ? @args.join(', ') : ""%><%= @method.downcase == "get" && parameters_doc.present? ? ", ...query" : ""%>}<%= @method.downcase != "get" && parameters_doc.present? ? ", body" : ""%>);
|
10
|
+
export const <%=@nickname.camelcase(:lower)%> = { type: '<%=@nickname.upcase%>', method: '<%=@method.downcase%>', key: '<%=reducer_key(@nickname, @args)%>', required: <%=js_args(@args)%> };
|
@@ -0,0 +1 @@
|
|
1
|
+
"<%=@nickname.upcase%>"
|
@@ -0,0 +1 @@
|
|
1
|
+
def <%=@nickname.downcase%>, do: "<%=@nickname.upcase%>"
|
@@ -0,0 +1 @@
|
|
1
|
+
def action(type), do: raise InvalidCanvasActionException, message: "Invalid Canvas API Action: #{type}}"
|
@@ -0,0 +1,18 @@
|
|
1
|
+
def action("<%=@nickname.upcase%>"), do: %{ uri: fn(<%= @args.present? ? "%{" : "" %><%=@args.map{|a| "\"#{a}\" => #{a}"}.join(', ')%><%= @args.present? ? "}" : "" %>) -> "<%=ruby_api_url(@api_url)%>" end, method: :<%=@method%>, parameters: [<%= @parameters.map do |p|
|
2
|
+
str = []
|
3
|
+
p.each do |property, value|
|
4
|
+
fixed_val = if value.is_a?(Hash)
|
5
|
+
"%#{value}"
|
6
|
+
elsif value.is_a?(String)
|
7
|
+
"\"#{value}\""
|
8
|
+
elsif value.nil?
|
9
|
+
"nil"
|
10
|
+
else
|
11
|
+
value
|
12
|
+
end
|
13
|
+
|
14
|
+
str << "\"#{property}\" => #{fixed_val}"
|
15
|
+
end
|
16
|
+
"%{ #{str.join(", ")} }"
|
17
|
+
end.join(", ")
|
18
|
+
%>] }
|
@@ -0,0 +1,9 @@
|
|
1
|
+
const <%=@model['id']%> = new GraphQLObjectType({
|
2
|
+
name: "<%=@model['id']%>",
|
3
|
+
description: "<%=@description%>. API Docs: https://canvas.instructure.com/doc/api/<%=@name%>.html",
|
4
|
+
fields() {
|
5
|
+
return {
|
6
|
+
<%=graphql_fields(@model, @resource_name).join(",\n ")%>
|
7
|
+
};
|
8
|
+
}
|
9
|
+
});
|
File without changes
|
@@ -0,0 +1,100 @@
|
|
1
|
+
import {
|
2
|
+
GraphQLBoolean,
|
3
|
+
GraphQLFloat,
|
4
|
+
GraphQLID,
|
5
|
+
GraphQLInt,
|
6
|
+
GraphQLList,
|
7
|
+
GraphQLNonNull,
|
8
|
+
GraphQLObjectType,
|
9
|
+
GraphQLSchema,
|
10
|
+
GraphQLString
|
11
|
+
} from 'graphql';
|
12
|
+
|
13
|
+
import {
|
14
|
+
connectionArgs,
|
15
|
+
connectionDefinitions,
|
16
|
+
connectionFromArray,
|
17
|
+
fromGlobalId,
|
18
|
+
globalIdField,
|
19
|
+
mutationWithClientMutationId,
|
20
|
+
nodeDefinitions,
|
21
|
+
connectionFromPromisedArray
|
22
|
+
} from 'graphql-relay';
|
23
|
+
|
24
|
+
import {
|
25
|
+
GraphQLLimitedString,
|
26
|
+
GraphQLDateTime
|
27
|
+
} from 'graphql-custom-types';
|
28
|
+
|
29
|
+
/**
|
30
|
+
* We get the node interface and field from the Relay library.
|
31
|
+
*
|
32
|
+
* The first method defines the way we resolve an ID to its object.
|
33
|
+
* The second defines the way we resolve an object to its GraphQL type.
|
34
|
+
*/
|
35
|
+
var {nodeInterface, nodeField} = nodeDefinitions(
|
36
|
+
(globalId) => {
|
37
|
+
var {type, id} = fromGlobalId(globalId);
|
38
|
+
if (type === 'User') {
|
39
|
+
return getUser(id);
|
40
|
+
} else if (type === 'Widget') {
|
41
|
+
return getWidget(id);
|
42
|
+
} else {
|
43
|
+
return null;
|
44
|
+
}
|
45
|
+
},
|
46
|
+
(obj) => {
|
47
|
+
if (obj instanceof User) {
|
48
|
+
return userType;
|
49
|
+
} else if (obj instanceof Widget) {
|
50
|
+
return widgetType;
|
51
|
+
} else {
|
52
|
+
return null;
|
53
|
+
}
|
54
|
+
}
|
55
|
+
);
|
56
|
+
|
57
|
+
<%=@content.join("\n\n")%>
|
58
|
+
|
59
|
+
const Query = new GraphQLObjectType({
|
60
|
+
name: 'Query',
|
61
|
+
description: 'Root query',
|
62
|
+
fields: () => ({
|
63
|
+
node: nodeField,
|
64
|
+
peopleRelay: {
|
65
|
+
type: PersonConnection,
|
66
|
+
description: 'Person connection test',
|
67
|
+
args: connectionArgs,
|
68
|
+
resolve (root, args) {
|
69
|
+
return connectionFromPromisedArray(Db.models.person.findAll(), args);
|
70
|
+
}
|
71
|
+
},
|
72
|
+
person: {
|
73
|
+
type: personType,
|
74
|
+
resolve (root, args) {
|
75
|
+
return Db.models.person.findOne({ where: args });
|
76
|
+
}
|
77
|
+
},
|
78
|
+
people: {
|
79
|
+
type: new GraphQLList(personType),
|
80
|
+
args: {
|
81
|
+
id: {
|
82
|
+
type: GraphQLInt
|
83
|
+
},
|
84
|
+
email: {
|
85
|
+
type: GraphQLString
|
86
|
+
}
|
87
|
+
},
|
88
|
+
resolve (root, args) {
|
89
|
+
return Db.models.person.findAll({ where: args });
|
90
|
+
}
|
91
|
+
}
|
92
|
+
})
|
93
|
+
});
|
94
|
+
|
95
|
+
const Schema = new GraphQLSchema({
|
96
|
+
query: Query,
|
97
|
+
//mutation: Mutuation
|
98
|
+
});
|
99
|
+
|
100
|
+
export default Schema;
|
@@ -0,0 +1 @@
|
|
1
|
+
<%=@nickname.upcase%>: { uri: function(args){return <%=js_url_parts(@api_url).join(' + ')%>}, method: "<%=@method%>", parameters: <%=@parameters.to_json%> }
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require_relative "canvas_base_type"
|
2
|
+
require_relative "canvas_base_input_type"
|
3
|
+
|
4
|
+
# The various types and input types generated for Canvas end up with circular dependencies
|
5
|
+
# We use a forward declaration here to overcome the problem.
|
6
|
+
# For example, the generate User class depends on Enrollment and Enrollment depends on User
|
7
|
+
# so we have a circular dependency.
|
8
|
+
module LMSGraphQL
|
9
|
+
module Types
|
10
|
+
module Canvas
|
11
|
+
<%= @content.join("\n ") %>
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require_relative "../canvas_base_input_type"
|
2
|
+
<%= require_from_properties(@model) %>
|
3
|
+
|
4
|
+
module LMSGraphQL
|
5
|
+
module Types
|
6
|
+
module Canvas
|
7
|
+
<%=graphql_field_enums(@model)-%>
|
8
|
+
class Canvas<%=@model['id'].singularize%>Input < BaseInputObject
|
9
|
+
description "<%=@description%>. API Docs: https://canvas.instructure.com/doc/api/<%=@name%>.html"
|
10
|
+
<%=graphql_fields(@model, @resource_name, true, true).join(" ")%>
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require_relative "../canvas_base_mutation"
|
2
|
+
<%= require_from_operation(operation) -%>
|
3
|
+
|
4
|
+
module LMSGraphQL
|
5
|
+
module Mutations
|
6
|
+
module Canvas
|
7
|
+
class <%= @nickname.classify %> < BaseMutation
|
8
|
+
<%= @parameters.map{|p| " argument :#{nested_arg(p["name"])}, #{graphql_type(p["name"], p, false, nil, true)}, required: #{p["required"]}"}.join("\n") %>
|
9
|
+
field :<%= name_from_operation(operation) %>, <%= type_from_operation(operation) %>, null: false
|
10
|
+
def resolve(<%= @parameters.map{|p| "#{nested_arg(p['name'])}#{p['required'] ? ':' : ': nil'}"}.join(", ") %>)
|
11
|
+
context[:canvas_api].call("<%= @nickname.upcase %>").proxy(
|
12
|
+
"<%= @nickname.upcase %>",
|
13
|
+
<%= params_as_string(@parameters, ["query", "path"])-%>,
|
14
|
+
<%= params_as_string(@parameters, ["form"])-%>,
|
15
|
+
).parsed_response
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
field :<%= @nickname %>, mutation: LMSGraphQL::Mutations::Canvas::<%= @nickname.classify %>
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require_relative "../../utils"
|
2
|
+
require_relative "../../types/canvas_base_type"
|
3
|
+
require_all(File.absolute_path(__FILE__), "../../mutations/canvas/")
|
4
|
+
|
5
|
+
module LMSGraphQL
|
6
|
+
module Mutations
|
7
|
+
module Canvas
|
8
|
+
class MutationType < LMSGraphQL::Types::Canvas::BaseType
|
9
|
+
description "Canvas API mutations"
|
10
|
+
|
11
|
+
<%= @content.join("\n ") %>
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require_relative "../canvas_base_resolver"
|
2
|
+
<%= require_from_operation(operation) -%>
|
3
|
+
|
4
|
+
module LMSGraphQL
|
5
|
+
module Resolvers
|
6
|
+
module Canvas
|
7
|
+
class <%= @nickname.classify %> < CanvasBaseResolver
|
8
|
+
type <%= type_from_operation(operation) %>, null: false
|
9
|
+
<% if operation["type"] == "array"
|
10
|
+
%> argument :get_all, Boolean, required: false
|
11
|
+
<% end
|
12
|
+
%><%= @parameters.map{|p| " argument :#{nested_arg(p["name"])}, #{graphql_type(p["name"], p)}, required: #{p["required"]}"}.join("\n") %>
|
13
|
+
def resolve(<%= @parameters.map{|p| "#{nested_arg(p['name'])}#{p['required'] ? ':' : ': nil'}"}.push("get_all: false").join(", ") %>)
|
14
|
+
result = context[:canvas_api].call("<%= @nickname.upcase %>").proxy(
|
15
|
+
"<%= @nickname.upcase %>",
|
16
|
+
{
|
17
|
+
<%= @parameters.map{|p| " \"#{p['name']}\": #{nested_arg(p['name'])}"}.join(",\n") -%>
|
18
|
+
},
|
19
|
+
nil,
|
20
|
+
get_all,
|
21
|
+
)
|
22
|
+
get_all ? result : result.parsed_response
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require_relative "../../utils"
|
2
|
+
require_relative "../canvas_base_type"
|
3
|
+
require_all(File.absolute_path(__FILE__), "../../resolvers/canvas/")
|
4
|
+
|
5
|
+
module LMSGraphQL
|
6
|
+
module Types
|
7
|
+
module Canvas
|
8
|
+
class QueryType < BaseType
|
9
|
+
description "The root query of Canvas schema"
|
10
|
+
<%= @content.join("\n\n ") %>
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require_relative "../canvas_base_type"
|
2
|
+
<%= require_from_properties(@model) %>
|
3
|
+
|
4
|
+
module LMSGraphQL
|
5
|
+
module Types
|
6
|
+
module Canvas
|
7
|
+
<%=graphql_field_enums(@model)-%>
|
8
|
+
class Canvas<%=@model['id'].singularize%> < BaseType
|
9
|
+
description "<%=@description%>. API Docs: https://canvas.instructure.com/doc/api/<%=@name%>.html"
|
10
|
+
<%=graphql_fields(@model, @resource_name).join(" ")%>
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
"<%=@nickname.upcase%>" => { uri: ->(<%=@args.map{|a| "#{a}:"}.join(', ')%>) { "<%=ruby_api_url(@api_url)%>" }, method: "<%=@method%>", parameters: <%=@parameters%> }
|
data/lib/lms/canvas.rb
ADDED
@@ -0,0 +1,447 @@
|
|
1
|
+
require "httparty"
|
2
|
+
require "active_support"
|
3
|
+
require "active_support/core_ext/object/blank"
|
4
|
+
require "active_support/core_ext/object/to_query"
|
5
|
+
require "active_support/core_ext/hash/keys"
|
6
|
+
require "active_support/core_ext/hash/indifferent_access"
|
7
|
+
require "ostruct"
|
8
|
+
|
9
|
+
require "lms/canvas_urls"
|
10
|
+
require "lms/helper_urls"
|
11
|
+
|
12
|
+
module LMS
|
13
|
+
|
14
|
+
class Canvas
|
15
|
+
|
16
|
+
# a model that encapsulates authentication state. By default, it
|
17
|
+
# is nil, but it may be set to any object that responds to:
|
18
|
+
# - #transaction { .. }
|
19
|
+
# - #lock(true) -> returns self
|
20
|
+
# - #find(id) -> returns an authentication object (see
|
21
|
+
# the `authentication` parameter of #initialize, below).
|
22
|
+
class <<self
|
23
|
+
attr_accessor :auth_state_model
|
24
|
+
end
|
25
|
+
|
26
|
+
# instance accessor, for convenience
|
27
|
+
def auth_state_model
|
28
|
+
self.class.auth_state_model
|
29
|
+
end
|
30
|
+
|
31
|
+
# callback must accept a single parameter (the API object itself)
|
32
|
+
# and return the new authentication object.
|
33
|
+
def self.on_auth(callback = nil, &block)
|
34
|
+
@@on_auth = callback || block
|
35
|
+
end
|
36
|
+
|
37
|
+
# set up a default auth callback. It assumes that #auth_state_model
|
38
|
+
# is set. If #auth_state_model will not be set, the client app must
|
39
|
+
# define a custom on_auth callback.
|
40
|
+
on_auth do |api|
|
41
|
+
api.lock do |record|
|
42
|
+
if record.token == api.authentication.token
|
43
|
+
record.update token: api.refresh_token
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
attr_reader :authentication
|
49
|
+
|
50
|
+
# The authentication parameter must be either a string (indicating
|
51
|
+
# a token), or an object that responds to:
|
52
|
+
# - #id
|
53
|
+
# - #token
|
54
|
+
# - #update(hash) -- which should update #token with hash[:token]:noh
|
55
|
+
def initialize(lms_uri, authentication, refresh_token_options = nil)
|
56
|
+
@per_page = 100
|
57
|
+
@lms_uri = lms_uri
|
58
|
+
@refresh_token_options = refresh_token_options
|
59
|
+
@authentication = if authentication.is_a?(String)
|
60
|
+
OpenStruct.new(token: authentication)
|
61
|
+
else
|
62
|
+
authentication
|
63
|
+
end
|
64
|
+
|
65
|
+
if refresh_token_options.present?
|
66
|
+
required_options = [:client_id, :client_secret, :redirect_uri, :refresh_token]
|
67
|
+
extra_options = @refresh_token_options.keys - required_options
|
68
|
+
unless extra_options.empty?
|
69
|
+
raise InvalidRefreshOptionsException,
|
70
|
+
"Invalid option(s) provided: #{extra_options.join(', ')}"
|
71
|
+
end
|
72
|
+
missing_options = required_options - @refresh_token_options.keys
|
73
|
+
unless missing_options.empty?
|
74
|
+
raise InvalidRefreshOptionsException,
|
75
|
+
"Missing required option(s): #{missing_options.join(', ')}"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Obtains a lock (via the API.auth_state_model interface) and
|
81
|
+
# yields an authentication object corresponding to
|
82
|
+
# self.authentication.id. The object is returned when the block
|
83
|
+
# finishes.
|
84
|
+
def lock
|
85
|
+
auth_state_model.transaction do
|
86
|
+
record = auth_state_model.
|
87
|
+
lock(true).
|
88
|
+
find(authentication.id)
|
89
|
+
|
90
|
+
yield record
|
91
|
+
|
92
|
+
record
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def headers(additional_headers = {})
|
97
|
+
{
|
98
|
+
"Authorization" => "Bearer #{@authentication.token}",
|
99
|
+
"User-Agent" => "LMS-API Ruby"
|
100
|
+
}.merge(additional_headers)
|
101
|
+
end
|
102
|
+
|
103
|
+
def full_url(api_url, use_api_prefix = true)
|
104
|
+
if api_url[0...4] == "http"
|
105
|
+
api_url
|
106
|
+
elsif use_api_prefix
|
107
|
+
"#{@lms_uri}/api/v1/#{api_url}"
|
108
|
+
else
|
109
|
+
"#{@lms_uri}/#{api_url}"
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def api_put_request(api_url, payload, additional_headers = {})
|
114
|
+
url = full_url(api_url)
|
115
|
+
refreshably do
|
116
|
+
HTTParty.put(url, headers: headers(additional_headers), body: payload)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def api_post_request(api_url, payload, additional_headers = {})
|
121
|
+
url = full_url(api_url)
|
122
|
+
refreshably do
|
123
|
+
HTTParty.post(url, headers: headers(additional_headers), body: payload)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def api_get_request(api_url, additional_headers = {})
|
128
|
+
url = full_url(api_url)
|
129
|
+
refreshably do
|
130
|
+
HTTParty.get(url, headers: headers(additional_headers))
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def api_delete_request(api_url, additional_headers = {})
|
135
|
+
url = full_url(api_url)
|
136
|
+
refreshably do
|
137
|
+
HTTParty.delete(url, headers: headers(additional_headers))
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def api_get_all_request(api_url, additional_headers = {})
|
142
|
+
[].tap do |results|
|
143
|
+
api_get_blocks_request(api_url, additional_headers) do |result|
|
144
|
+
results.concat(result)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def api_get_blocks_request(api_url, additional_headers = {})
|
150
|
+
connector = api_url.include?("?") ? "&" : "?"
|
151
|
+
next_url = "#{api_url}#{connector}per_page=#{@per_page}"
|
152
|
+
while next_url
|
153
|
+
result = api_get_request(next_url, additional_headers)
|
154
|
+
yield result
|
155
|
+
next_url = get_next_url(result.headers["link"])
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def force_refresh
|
160
|
+
@authentication = @@on_auth.call(self)
|
161
|
+
end
|
162
|
+
|
163
|
+
def refreshably
|
164
|
+
refresh_attempts = 0
|
165
|
+
|
166
|
+
begin
|
167
|
+
result = yield
|
168
|
+
check_result(result)
|
169
|
+
rescue LMS::Canvas::RefreshTokenRequired => ex
|
170
|
+
raise ex if @refresh_token_options.blank?
|
171
|
+
|
172
|
+
refresh_attempts += 1
|
173
|
+
|
174
|
+
@authentication = @@on_auth.call(self)
|
175
|
+
|
176
|
+
if refresh_attempts < 2
|
177
|
+
retry
|
178
|
+
else
|
179
|
+
raise LMS::Canvas::InvalidTokenException.new(
|
180
|
+
"Refreshing the token gives an invalid token. The developer key may have been disabled.",
|
181
|
+
result&.response&.code&.to_i,
|
182
|
+
result,
|
183
|
+
@authentication
|
184
|
+
)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def refresh_token
|
190
|
+
payload = {
|
191
|
+
grant_type: "refresh_token"
|
192
|
+
}.merge(@refresh_token_options)
|
193
|
+
url = full_url("login/oauth2/token", false)
|
194
|
+
result = HTTParty.post(url, headers: headers, body: payload)
|
195
|
+
code = result.response.code.to_i
|
196
|
+
if code >= 500
|
197
|
+
raise LMS::Canvas::RefreshToken500Exception.new(api_error(result), code, result, @authentication)
|
198
|
+
end
|
199
|
+
|
200
|
+
if code > 201
|
201
|
+
raise LMS::Canvas::RefreshTokenFailedException.new(api_error(result), code, result, @authentication)
|
202
|
+
end
|
203
|
+
result["access_token"]
|
204
|
+
end
|
205
|
+
|
206
|
+
def check_result(result)
|
207
|
+
code = result.response.code.to_i
|
208
|
+
|
209
|
+
return result if [200, 201, 202, 203, 204, 205, 206].include?(code)
|
210
|
+
|
211
|
+
if code == 401 && result.headers["www-authenticate"] == 'Bearer realm="canvas-lms"'
|
212
|
+
raise LMS::Canvas::RefreshTokenRequired.new("", nil, result, @authentication)
|
213
|
+
end
|
214
|
+
|
215
|
+
raise LMS::Canvas::InvalidAPIRequestException.new(api_error(result), code, result)
|
216
|
+
end
|
217
|
+
|
218
|
+
def api_error(result)
|
219
|
+
error = "Status: #{result.headers['status']} \n"
|
220
|
+
error << "Http Response: #{result.response.code} \n"
|
221
|
+
error << "Error: #{result.response.message} \n"
|
222
|
+
end
|
223
|
+
|
224
|
+
def get_next_url(link)
|
225
|
+
return nil if link.blank?
|
226
|
+
if url = link.split(",").detect { |l| l.split(";")[1].strip == 'rel="next"' }
|
227
|
+
url.split(";")[0].gsub(/[\<\>\s]/, "")
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
def multi_proxy(type, params, payload = nil, get_all = false)
|
232
|
+
# Helper methods call several Canvas methods to return a block of data to the client
|
233
|
+
if helper = CANVAS_HELPER_URLs[type]
|
234
|
+
result = self.send(helper)
|
235
|
+
return OpenStruct.new(
|
236
|
+
code: 200,
|
237
|
+
headers: {},
|
238
|
+
body: result.to_json
|
239
|
+
)
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
def single_proxy(type, params, payload = nil, get_all = false)
|
244
|
+
additional_headers = {
|
245
|
+
"Content-Type" => "application/json"
|
246
|
+
}
|
247
|
+
payload = {} if payload.blank?
|
248
|
+
payload_json = payload.is_a?(String) ? payload : payload.to_json
|
249
|
+
parsed_payload = payload.is_a?(String) ? JSON.parse(payload) : payload
|
250
|
+
parsed_payload = parsed_payload.with_indifferent_access
|
251
|
+
|
252
|
+
method = LMS::CANVAS_URLs[type][:method]
|
253
|
+
url = LMS::Canvas.lms_url(type, params, parsed_payload)
|
254
|
+
|
255
|
+
case method
|
256
|
+
when "GET"
|
257
|
+
if block_given?
|
258
|
+
api_get_blocks_request(url, additional_headers) do |result|
|
259
|
+
yield result
|
260
|
+
end
|
261
|
+
elsif get_all
|
262
|
+
api_get_all_request(url, additional_headers)
|
263
|
+
else
|
264
|
+
api_get_request(url, additional_headers)
|
265
|
+
end
|
266
|
+
when "POST"
|
267
|
+
api_post_request(url, payload_json, additional_headers)
|
268
|
+
when "PUT"
|
269
|
+
api_put_request(url, payload_json, additional_headers)
|
270
|
+
when "DELETE"
|
271
|
+
api_delete_request(url, additional_headers)
|
272
|
+
else
|
273
|
+
raise LMS::Canvas::InvalidAPIMethodRequestException "Invalid method type: #{method}"
|
274
|
+
end
|
275
|
+
|
276
|
+
rescue LMS::Canvas::InvalidAPIRequestException => ex
|
277
|
+
error = "#{ex.message} \n"
|
278
|
+
error << "API Request Url: #{url} \n"
|
279
|
+
error << "API Request Params: #{params} \n"
|
280
|
+
error << "API Request Payload: #{payload} \n"
|
281
|
+
error << "API Request Result: #{ex.result.body} \n"
|
282
|
+
new_ex = LMS::Canvas::InvalidAPIRequestFailedException.new(error, ex.status, ex.result)
|
283
|
+
new_ex.set_backtrace(ex.backtrace)
|
284
|
+
raise new_ex
|
285
|
+
end
|
286
|
+
|
287
|
+
def proxy(type, params, payload = nil, get_all = false, &block)
|
288
|
+
multi_proxy(type, params, payload, get_all) ||
|
289
|
+
single_proxy(type, params, payload, get_all, &block)
|
290
|
+
end
|
291
|
+
|
292
|
+
# Ignore required params for specific calls. For example, the external tool calls
|
293
|
+
# have required params "name, privacy_level, consumer_key, shared_secret". However, those
|
294
|
+
# params are not required if the call specifies config_type: "by_xml".
|
295
|
+
def self.ignore_required(type)
|
296
|
+
[
|
297
|
+
"CREATE_EXTERNAL_TOOL_COURSES",
|
298
|
+
"CREATE_EXTERNAL_TOOL_ACCOUNTS",
|
299
|
+
].include?(type)
|
300
|
+
end
|
301
|
+
|
302
|
+
# These methods allow custom paths to be appended to the API endpoint.
|
303
|
+
def self.allow_scoped_path(type)
|
304
|
+
[
|
305
|
+
"STORE_CUSTOM_DATA",
|
306
|
+
"LOAD_CUSTOM_DATA",
|
307
|
+
"DELETE_CUSTOM_DATA",
|
308
|
+
].include?(type)
|
309
|
+
end
|
310
|
+
|
311
|
+
def self.lms_url(type, params, payload = nil)
|
312
|
+
endpoint = LMS::CANVAS_URLs[type]
|
313
|
+
parameters = endpoint[:parameters]
|
314
|
+
|
315
|
+
# Make sure all required parameters are present
|
316
|
+
missing = []
|
317
|
+
if !ignore_required(type)
|
318
|
+
parameters.select { |p| p["required"] }.map { |p| p["name"] }.each do |p|
|
319
|
+
if p.include?("[") && p.include?("]")
|
320
|
+
parts = p.split("[")
|
321
|
+
parent = parts[0].to_sym
|
322
|
+
child = parts[1].gsub("]", "").to_sym
|
323
|
+
missing << p unless (params[parent].present? && params[parent][child].present?) ||
|
324
|
+
(payload.present? && payload[parent].present? && payload[parent][child].present?)
|
325
|
+
else
|
326
|
+
missing << p unless params[p.to_sym].present? ||
|
327
|
+
(payload.present? && !payload.is_a?(String) && payload[p.to_sym].present?)
|
328
|
+
end
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
if !missing.empty?
|
333
|
+
raise LMS::Canvas::MissingRequiredParameterException,
|
334
|
+
"Missing required parameter(s): #{missing.join(', ')}"
|
335
|
+
end
|
336
|
+
|
337
|
+
# Generate the uri. Only allow path parameters
|
338
|
+
uri_proc = endpoint[:uri]
|
339
|
+
path_parameters = parameters.select { |p| p["paramType"] == "path" }.
|
340
|
+
map { |p| p["name"].to_sym }
|
341
|
+
args = params.slice(*path_parameters).deep_symbolize_keys
|
342
|
+
uri = args.blank? ? uri_proc.call : uri_proc.call(**args)
|
343
|
+
|
344
|
+
# Handle scopes in the url. These API endpoints allow for additional path
|
345
|
+
# information to be added to their urls.
|
346
|
+
# ie "users/#{user_id}/custom_data/favorite_color/green"
|
347
|
+
if allow_scoped_path(type) &&
|
348
|
+
scope = params[:scope]&.gsub("../", "").gsub("..", "") # Don't allow moving up in the path
|
349
|
+
uri = File.join(uri, scope)
|
350
|
+
end
|
351
|
+
|
352
|
+
# Generate the query string
|
353
|
+
query_parameters = parameters.select { |p| p["paramType"] == "query" }.
|
354
|
+
map { |p| p["name"].to_sym }
|
355
|
+
|
356
|
+
# always allow paging parameters
|
357
|
+
query_parameters << :per_page
|
358
|
+
query_parameters << :page
|
359
|
+
query_parameters << :as_user_id
|
360
|
+
|
361
|
+
allowed_params = params.
|
362
|
+
slice(*query_parameters).
|
363
|
+
reject { |key, value| value.nil? }
|
364
|
+
|
365
|
+
if allowed_params.present?
|
366
|
+
"#{uri}?#{allowed_params.to_query}"
|
367
|
+
else
|
368
|
+
uri
|
369
|
+
end
|
370
|
+
end
|
371
|
+
|
372
|
+
#
|
373
|
+
# Helper methods
|
374
|
+
#
|
375
|
+
|
376
|
+
# Get all accounts including sub accounts
|
377
|
+
def all_accounts
|
378
|
+
all = []
|
379
|
+
single_proxy("LIST_ACCOUNTS", {}, nil, true).each do |account|
|
380
|
+
all << account
|
381
|
+
sub_accounts = single_proxy("GET_SUB_ACCOUNTS_OF_ACCOUNT",
|
382
|
+
{
|
383
|
+
account_id: account["id"],
|
384
|
+
recursive: true,
|
385
|
+
},
|
386
|
+
nil,
|
387
|
+
true)
|
388
|
+
all = all.concat(sub_accounts)
|
389
|
+
end
|
390
|
+
all
|
391
|
+
end
|
392
|
+
|
393
|
+
#
|
394
|
+
# Exceptions
|
395
|
+
#
|
396
|
+
|
397
|
+
class CanvasException < RuntimeError
|
398
|
+
attr_reader :status
|
399
|
+
attr_reader :message
|
400
|
+
attr_reader :result
|
401
|
+
|
402
|
+
def initialize(message = "", status = nil, result = nil)
|
403
|
+
super(message)
|
404
|
+
|
405
|
+
@message = message
|
406
|
+
@status = status
|
407
|
+
@result = result
|
408
|
+
end
|
409
|
+
end
|
410
|
+
|
411
|
+
class TokenException < CanvasException
|
412
|
+
attr_reader :auth
|
413
|
+
|
414
|
+
def initialize(message = "", status = nil, result = nil, auth = nil)
|
415
|
+
super(message, status, result)
|
416
|
+
@auth = auth
|
417
|
+
end
|
418
|
+
end
|
419
|
+
|
420
|
+
class RefreshTokenRequired < TokenException
|
421
|
+
end
|
422
|
+
|
423
|
+
class RefreshTokenFailedException < TokenException
|
424
|
+
end
|
425
|
+
|
426
|
+
class RefreshToken500Exception < TokenException
|
427
|
+
end
|
428
|
+
|
429
|
+
class InvalidRefreshOptionsException < CanvasException
|
430
|
+
end
|
431
|
+
|
432
|
+
class InvalidAPIRequestException < CanvasException
|
433
|
+
end
|
434
|
+
|
435
|
+
class InvalidAPIRequestFailedException < CanvasException
|
436
|
+
end
|
437
|
+
|
438
|
+
class InvalidAPIMethodRequestException < CanvasException
|
439
|
+
end
|
440
|
+
|
441
|
+
class MissingRequiredParameterException < CanvasException
|
442
|
+
end
|
443
|
+
|
444
|
+
class InvalidTokenException < TokenException
|
445
|
+
end
|
446
|
+
end
|
447
|
+
end
|