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.
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