# frozen_string_literal: true require "faraday" module PlatformSdk module CanvasApiWrapper # DataPipeline::Client class Client attr_reader :host, :token, :connection PAGE_SIZE = 10 # @param domain [String] # @param token [String] def initialize(domain:, token:) @host = "https://#{domain}" @token = token @connection = Faraday.new(url: host, headers: { 'Authorization' => "Bearer #{token}" }) end # @param course_id [Integer] # @returns [Array] def course_modules(course_id:) response = @connection.get("/api/v1/courses/#{course_id}/modules") handle_rate_limiting response if success?(response.status) paginated_items headers: response.headers, initial_response: response.body else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error getting course modules: #{error_messages}" end end # @param course_id [Integer] # @param module_id [Integer] # @returns [Array] def module_items(course_id:, module_id:) response = @connection.get("/api/v1/courses/#{course_id}/modules/#{module_id}/items?per_page=#{PAGE_SIZE}") handle_rate_limiting response if success?(response.status) paginated_items headers: response.headers, initial_response: response.body else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error getting module items: #{error_messages}" end end # @param course_id [Integer] # @param module_id [Integer] # @param user_id [Integer] # @returns [Array] def user_module_items(course_id:, module_id:, user_id:) uri = "/api/v1/courses/#{course_id}/modules/#{module_id}/items?as_user_id=#{user_id}&per_page=#{PAGE_SIZE}" response = @connection.get(uri) handle_rate_limiting response if success?(response.status) paginated_items headers: response.headers, initial_response: response.body else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error getting module items: #{error_messages}" end end # @param course_id [Integer] # @param module_id [Integer] # @returns [Array] def assignments(course_id:, module_id:) response = @connection.get("/api/v1/courses/#{course_id}/modules/#{module_id}/items?per_page=#{PAGE_SIZE}") handle_rate_limiting response if success?(response.status) assignments = paginated_items headers: response.headers, initial_response: response.body assignments.select { |item| item[:type] == 'Assignment' } else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error getting assignments: #{error_messages}" end end # @param course_id [Integer] def assignment(course_id:, assignment_id:) uri = "api/v1/courses/#{course_id}/assignments/#{assignment_id}" response = @connection.get(uri) handle_rate_limiting response if success?(response.status) MultiJson.load response.body, symbolize_keys: true else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error getting quiz submissions#{error_messages}" end end # @param course_id [Integer] # @param module_id [Integer] # @param module_item_id [Integer] def module_item(course_id:, module_id:, module_item_id:) response = @connection.get("/api/v1/courses/#{course_id}/modules/#{module_id}/items/#{module_item_id}") handle_rate_limiting response if success?(response.status) MultiJson.load response.body, symbolize_keys: true else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error getting module item: #{error_messages}" end end def user_module_item(course_id:, module_id:, module_item_id:, user_id:) response = @connection.get("/api/v1/courses/#{course_id}/modules/#{module_id}/items/#{module_item_id}?as_user_id=#{user_id}") handle_rate_limiting response if success?(response.status) MultiJson.load response.body, symbolize_keys: true else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error getting module item: #{error_messages}" end end # @param course_id [Integer] # @param assignment_id [Integer] def submissions(course_id:, assignment_id:) response = @connection.get("/api/v1/courses/#{course_id}/assignments/#{assignment_id}/submissions/") handle_rate_limiting response if success?(response.status) paginated_items headers: response.headers, initial_response: response.body else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error getting submissions: #{error_messages}" end end def update_submission_excused(course_id:, assignment_id:, user_id:) uri = "/api/v1/courses/#{course_id}/assignments/#{assignment_id}/submissions/#{user_id}" response = @connection.put(uri) do |req| req.headers["Content-Type"] = "application/json" req.body = { submission: { excuse: true } }.to_json end handle_rate_limiting response if success?(response.status) MultiJson.load response.body, symbolize_keys: true else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error update submissions to excused: #{error_messages}" end end def update_submission_excused_with_comment(course_id:, assignment_id:, user_id:, comment:) uri = "/api/v1/courses/#{course_id}/assignments/#{assignment_id}/submissions/#{user_id}" response = @connection.put(uri) do |req| req.headers['Content-Type'] = 'application/json' req.body = { 'comment': { 'text_comment': comment }, 'submission': { 'excuse': true } }.to_json end if success?(response.status) MultiJson.load response.body, symbolize_keys: true else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error update submissions to excused: #{error_messages}" end end # @param course_id [Integer] def course_students(course_id:) uri = "/api/v1/courses/#{course_id}/users?per_page=#{PAGE_SIZE}" params = { 'enrollment_type[]' => 'student' } response = @connection.get(uri, params) handle_rate_limiting response if success?(response.status) paginated_items headers: response.headers, initial_response: response.body else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error getting course students: #{error_messages}" end end # @param course_id [Integer] def course_test_student(course_id:) uri = "/api/v1/courses/#{course_id}/users" params = { 'enrollment_type[]' => 'student_view' } response = @connection.get(uri, params) handle_rate_limiting response if success?(response.status) MultiJson.load response.body, symbolize_keys: true else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error getting test students: #{error_messages}" end end # @param course_id [Integer] def course_users(course_id:) uri = "/api/v1/courses/#{course_id}/users?per_page=#{PAGE_SIZE}" response = @connection.get(uri) handle_rate_limiting response if success?(response.status) paginated_items headers: response.headers, initial_response: response.body else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error getting course users: #{error_messages}" end end # @param course_id [Integer] # @param quiz_id [Integer] def quiz(course_id:, quiz_id:) uri = "/api/v1/courses/#{course_id}/quizzes/#{quiz_id}" response = @connection.get(uri) handle_rate_limiting response if success?(response.status) MultiJson.load response.body, symbolize_keys: true else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error getting quiz: #{error_messages}" end end # @param course_id [Integer] # @param quiz_id [Integer] # @param user_id [Integer] # @return [Array] def quiz_submissions(course_id:, quiz_id:, user_id:) uri = "api/v1/courses/#{course_id}/quizzes/#{quiz_id}/submissions?user=#{user_id}&per_page=#{PAGE_SIZE}" response = @connection.get(uri) handle_rate_limiting response if success?(response.status) paginated_items headers: response.headers, initial_response: response.body else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors].each do |error| error_messages << "#{error[:message]} " end raise "Error getting quiz submissions#{error_messages}" end end # @param course_id [Integer] # @param quiz_id [Integer] # @param user_id [Integer] def post_create_quiz_submission(course_id:, quiz_id:, user_id:) uri = "api/v1/courses/#{course_id}/quizzes/#{quiz_id}/submissions?as_user_id=#{user_id}" response = @connection.post(uri) handle_rate_limiting response if success?(response.status) MultiJson.load response.body, symbolize_keys: true else handle_post_response_errors(response, 'Error creating quiz submission') end end # @param course_id [Integer] # @param quiz_id [Integer] # @param submission_id [Integer] # @param attempt [Integer] # @param validation_token [String] # @return [Hash] def post_complete_quiz_submission(course_id:, quiz_id:, submission_id:, attempt:, validation_token:) uri = "api/v1/courses/#{course_id}/quizzes/#{quiz_id}/submissions/#{submission_id}/complete" response = @connection.post(uri) do |req| req.headers['Content-Type'] = 'application/json' req.body = { attempt:, validation_token: }.to_json end handle_rate_limiting response if success?(response.status) MultiJson.load response.body, symbolize_keys: true else handle_post_response_errors(response, 'Error completing quiz submission') end end # @param course_id [Integer] # @param module_id [Integer] # @param module_item_id [Integer] # @param user_id [Integer] # @return nil def post_mark_module_item_read(course_id:, module_id:, module_item_id:, user_id:) uri = "api/v1/courses/#{course_id}/modules/#{module_id}/items/#{module_item_id}/mark_read?as_user_id=#{user_id}" response = @connection.post(uri) handle_rate_limiting response if success?(response.status) MultiJson.load response.body, symbolize_keys: true else handle_post_response_errors(response, 'Error marking module items read') end end # @param course_id [Integer] # @param module_id [Integer] # @param module_item_id [Integer] # @param user_id [Integer] # @return nil def post_mark_module_item_done(course_id:, module_id:, module_item_id:, user_id:) uri = "api/v1/courses/#{course_id}/modules/#{module_id}/items/#{module_item_id}/done?as_user_id=#{user_id}" response = @connection.post(uri) handle_rate_limiting response if success?(response.status) MultiJson.load response.body, symbolize_keys: true else handle_post_response_errors(response, 'Error marking module item done') end end def post_discussion_topic_entry(course_id:, topic_id:, user_id:, message:) uri = "api/v1/courses/#{course_id}/discussion_topics/#{topic_id}/entries?as_user_id=#{user_id}" response = @connection.post(uri) do |req| req.headers['Content-Type'] = 'application/json' req.body = { 'message': message.to_s }.to_json end handle_rate_limiting response if success?(response.status) MultiJson.load response.body, symbolize_keys: true else result = MultiJson.load response.body, symbolize_keys: true error_messages = String.new result[:errors]&.each do |error| error_messages << "#{error[:message]} " end raise "Error posting a reply: #{error_messages}" end end # @param course_id [Integer] # @param module_id [Integer] def put_relock_module(course_id:, module_id:) uri = "api/v1/courses/#{course_id}/modules/#{module_id}/relock" response = @connection.put(uri) result = MultiJson.load response.body, symbolize_keys: true handle_rate_limiting response if success?(response.status) result else error_message = parse_response_errors(result:, message: 'Error re-locking module') raise CanvasClientError, error_message end end # @param course_id [Integer] # @param module_id [Integer] # @param prerequisite_ids [Array] # @note prerequisite_ids should be a valid module_id, sending and empty array removes all prerequisites. def put_module_prerequesites(course_id:, module_id:, prerequisite_ids:) uri = "/api/v1/courses/#{course_id}/modules/#{module_id}" response = @connection.put(uri) do |req| req.headers['Content-Type'] = 'application/x-www-form-urlencoded' req.body = { 'module[prerequisite_module_ids]' => prerequisite_ids } end handle_rate_limiting response result = MultiJson.load response.body, symbolize_keys: true if success?(response.status) result else error_messages = String.new result[:errors]&.each do |error| error_messages << "#{error[:message]} " end raise "Error updating module prerequisite: #{error_messages}" end end # @param course_id [Integer] # @param module_id [Integer] # @param body [Hash] # @option body [String] 'module_item[title]' The name of the module_item and associated content # @option body [String] 'module_item[type]' Allowed: ExternalUrl, ExternalTool # @option body [Integer] 'module_item[content_id]' The id of the content to link to the module_item. Required # @option body [Integer] 'module_item[position]' The position of this item in the module (1-based). Default 0 # @option body [String] 'module_item[external_url]' External url that the item points to. Required for: ExternalUrl, ExternalTool. # @option body [String] 'module_item[completion_requirement][type]' Allowed: must_view, must_contribute, must_submit, must_mark_done. def post_module_item(course_id:, module_id:, body:) uri = "/api/v1/courses/#{course_id}/modules/#{module_id}/items" response = @connection.post(uri) do |req| req.headers['Content-Type'] = 'application/x-www-form-urlencoded' req.body = body end handle_rate_limiting response result = MultiJson.load response.body, symbolize_keys: true if success?(response.status) result else error_messages = String.new result[:errors]&.each do |error| error_messages << "#{error[:message]} " end raise "Error creating module item: #{error_messages}" end end # @param course_id [Integer] # @param module_id [Integer] # @param module_item_id [Integer] def put_publish_module_item(course_id:, module_id:, module_item_id:) uri = "/api/v1/courses/#{course_id}/modules/#{module_id}/items/#{module_item_id}" response = @connection.put(uri) do |req| req.headers['Content-Type'] = 'application/x-www-form-urlencoded' req.body = { 'module_item[published]' => true } end handle_rate_limiting response result = MultiJson.load response.body, symbolize_keys: true if success?(response.status) result else error_messages = String.new result[:errors]&.each do |error| error_messages << "#{error[:message]} " end raise "Error publishing module item: #{error_messages}" end end def delete_module_item(course_id:, module_id:, module_item_id:) uri = "/api/v1/courses/#{course_id}/modules/#{module_id}/items/#{module_item_id}" response = @connection.delete(uri) do |req| req.headers['Content-Type'] = 'application/x-www-form-urlencoded' end handle_rate_limiting response result = MultiJson.load response.body, symbolize_keys: true if success?(response.status) result else error_messages = String.new result[:errors]&.each do |error| error_messages << "#{error[:message]} " end raise "Error destroying module item: #{error_messages}" end end def retrieve_discussion_topic(course_id:, topic_id:) uri = "/api/v1/courses/#{course_id}/discussion_topics/#{topic_id}" response = @connection.get(uri) do |req| req.headers['Content-Type'] = 'application/json' end handle_rate_limiting response result = MultiJson.load response.body, symbolize_keys: true if success?(response.status) result else error_messages = String.new result[:errors]&.each do |error| error_messages << "#{error[:message]} " end raise "Error retrieving discussion topic: #{error_messages}" end end private # @param headers [Faraday::Utils::Headers] # @return [Array] def paginated_items(headers:, initial_response:) pages = page_links(headers:) items = [] items.append MultiJson.load(initial_response, symbolize_keys: true) while pages[:current] != pages[:last] response = @connection.get(pages[:next][:url]) handle_rate_limiting response pages = page_links(headers: response.headers) items.append MultiJson.load(response.body, symbolize_keys: true) end items.flatten end # @param headers [Faraday::Utils::Headers] # @return [Hash] def page_links(headers:) parts = headers[:link].split(',') parts.map do |part, _| section = part.split(';') name = section[1][/rel="(.*)"/, 1].to_sym url = section[0][/<(.*)>/, 1] [name, { url: }] end.to_h end # @param response_code [Integer] # @return [Boolean] def success?(response_code) !!(response_code.to_s =~ /2[0-9]/) end # @param message [String] # @param result [Hash] # @return [String] def parse_response_errors(message:, result:) error_messages = String.new unless result[:errors].nil? result[:errors].each do |error| error_messages << "#{error[:message]} " end end "#{message}: #{error_messages}" end def handle_rate_limiting(response) remaining_requests = response.headers["X-Rate-Limit-Remaining"].to_i reset_time = response.headers["X-Rate-Limit-Reset"].to_i return unless remaining_requests.zero? sleep_time = [reset_time - Time.now.to_i, 0].max + 1 sleep(sleep_time) end # @param response [Faraday::Response] # @param message [String] def handle_post_response_errors(response, message) message = "#{message},\n" + "host: #{host},\n" + "response: #{response.body}" case response.status when 403 raise LockedModuleItemError, message when 404 raise MissingModuleItemError, message when 401 raise UnauthorizedError, message else raise CanvasClientError, message end end end class CanvasClientError < StandardError; end class LockedModuleItemError < CanvasClientError; end class MissingModuleItemError < CanvasClientError; end class UnauthorizedError < CanvasClientError; end end end