lms-api 1.17.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +84 -0
  4. data/Rakefile +27 -0
  5. data/lib/canvas_api.rb +4 -0
  6. data/lib/canvas_api/builder.rb +121 -0
  7. data/lib/canvas_api/js_graphql_helpers.rb +98 -0
  8. data/lib/canvas_api/js_helpers.rb +58 -0
  9. data/lib/canvas_api/rb_graphql_helpers.rb +241 -0
  10. data/lib/canvas_api/render.rb +74 -0
  11. data/lib/canvas_api/ruby_helpers.rb +9 -0
  12. data/lib/canvas_api/templates/constant.erb +10 -0
  13. data/lib/canvas_api/templates/constants.erb +4 -0
  14. data/lib/canvas_api/templates/course_id_required.erb +1 -0
  15. data/lib/canvas_api/templates/course_ids_required.erb +5 -0
  16. data/lib/canvas_api/templates/ex_action.erb +1 -0
  17. data/lib/canvas_api/templates/ex_default_action.erb +1 -0
  18. data/lib/canvas_api/templates/ex_url.erb +18 -0
  19. data/lib/canvas_api/templates/ex_urls.erb +3 -0
  20. data/lib/canvas_api/templates/js_graphql_model.erb +9 -0
  21. data/lib/canvas_api/templates/js_graphql_mutation.erb +0 -0
  22. data/lib/canvas_api/templates/js_graphql_mutations.erb +6 -0
  23. data/lib/canvas_api/templates/js_graphql_queries.erb +7 -0
  24. data/lib/canvas_api/templates/js_graphql_query.erb +7 -0
  25. data/lib/canvas_api/templates/js_graphql_types.erb +100 -0
  26. data/lib/canvas_api/templates/js_url.erb +1 -0
  27. data/lib/canvas_api/templates/js_urls.erb +3 -0
  28. data/lib/canvas_api/templates/rb_forward_declarations.erb +14 -0
  29. data/lib/canvas_api/templates/rb_graphql_field.erb +3 -0
  30. data/lib/canvas_api/templates/rb_graphql_input_type.erb +14 -0
  31. data/lib/canvas_api/templates/rb_graphql_mutation.erb +20 -0
  32. data/lib/canvas_api/templates/rb_graphql_mutation_include.erb +1 -0
  33. data/lib/canvas_api/templates/rb_graphql_mutations.erb +15 -0
  34. data/lib/canvas_api/templates/rb_graphql_resolver.erb +27 -0
  35. data/lib/canvas_api/templates/rb_graphql_root_query.erb +14 -0
  36. data/lib/canvas_api/templates/rb_graphql_type.erb +14 -0
  37. data/lib/canvas_api/templates/rb_url.erb +1 -0
  38. data/lib/canvas_api/templates/rb_urls.erb +5 -0
  39. data/lib/lms/canvas.rb +447 -0
  40. data/lib/lms/canvas_urls.rb +812 -0
  41. data/lib/lms/course_ids_required.rb +309 -0
  42. data/lib/lms/helper_urls.rb +8 -0
  43. data/lib/lms/utils.rb +6 -0
  44. data/lib/lms/version.rb +3 -0
  45. data/lib/lms_api.rb +4 -0
  46. data/lib/tasks/canvas_api.rake +23 -0
  47. 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,9 @@
1
+ module CanvasApi
2
+ module RubyHelpers
3
+
4
+ def ruby_api_url(api_url)
5
+ api_url.gsub("{", "#\{")
6
+ end
7
+
8
+ end
9
+ 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,4 @@
1
+ //
2
+ // <%= @description %>
3
+ //
4
+ <%=@content.join("\n\n")%>
@@ -0,0 +1 @@
1
+ "<%=@nickname.upcase%>"
@@ -0,0 +1,5 @@
1
+ module LMS
2
+ COURSE_REQUIRED_ENDPOINTS = [
3
+ <%=@content.join(",\n ")%>
4
+ ].freeze
5
+ end
@@ -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,3 @@
1
+ defmodule Canvas.Actions do
2
+ <%=@content.join("\n ")%>
3
+ end
@@ -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
+ });
@@ -0,0 +1,6 @@
1
+ const Mutuation = new GraphQLObjectType({
2
+ name: 'Canvas Api Mutations',
3
+ fields: {
4
+ <%=@content.join("\n\n")%>
5
+ }
6
+ });
@@ -0,0 +1,7 @@
1
+ const Query = new GraphQLObjectType({
2
+ name: 'Canvas Api Queries',
3
+ description: "Root of the Canvas Api",
4
+ fields: () => ({
5
+ <%=@content.join("\n\n")%>
6
+ })
7
+ });
@@ -0,0 +1,7 @@
1
+ <%=@nickname%>: {
2
+ type: new GraphQLList(Post),
3
+ description: "<%=@description%>",
4
+ resolve: function(context, args) {
5
+ return canvasRequest(context, args, urls.<%=@nickname.upcase%>);
6
+ }
7
+ }
@@ -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,3 @@
1
+ module.exports = {
2
+ <%=@content.join(",\n ")%>
3
+ };
@@ -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,3 @@
1
+ field :<%= @nickname %>,
2
+ resolver: LMSGraphQL::Resolvers::Canvas::<%= @nickname.classify %>,
3
+ description: "<%= @summary %>. <%= @notes.gsub(/\n+/, " ").gsub("//", " ").gsub('"', "'") %>"
@@ -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%> }
@@ -0,0 +1,5 @@
1
+ module LMS
2
+ CANVAS_URLs = {
3
+ <%=@content.join(",\n ")%>
4
+ }
5
+ end
@@ -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