module CanvasSync::Concerns module ApiSyncable extend ActiveSupport::Concern NON_EXISTANT_ERRORS = [Faraday::Error::ResourceNotFound, Footrest::HttpError::NotFound] class_methods do def find_or_fetch(canvas_id, save: false, retries: 1, **kwargs) inst = find_or_initialize_by(canvas_id: canvas_id) return inst if inst.persisted? api_response = inst.request_from_api(retries: retries, **kwargs) api_sync_race_create!(inst, save: save) do |inst2| inst2.assign_from_api_params(api_response, **kwargs) end rescue *NON_EXISTANT_ERRORS nil end def find_or_fetch!(*args) inst = find_or_fetch(*args) raise ActiveRecord::RecordNotFound unless inst.present? inst end def create_or_update_from_api_params(api_params) api_params = api_params.with_indifferent_access api_sync_race_create!(api_params[:id]) do |inst| inst.assign_from_api_params(api_params) end end def api_sync_options=(opts) @api_sync_options = opts end def api_sync_options @api_sync_options || superclass.try(:api_sync_options) end # Define the model as being syncable via the Canvas API and configure sync options/parameters # @param [Hash] map A hash of local_field => (:api_response_key | ->(api_response){ value }) # @param [->(bearcat?){ api_response }] fetch # @param [Hash] options # @option options [] :mark_deleted Hash to be merged | Symbol to invoke | ->(){ } # @yield [api_response, [mapped_data]] Callback to merge data into a Model instance def api_syncable(map, fetch, options={}, &blk) default_options = { mark_deleted: -> { %i[workflow_state= status=].each do |sym| next unless self.respond_to?(sym) self.send(sym, 'deleted') return end }, field_map: map, fetch_from_api: fetch, process_response: blk, } default_options.merge!(options) self.api_sync_options = default_options.merge!(options) end private def api_sync_race_create!(inst, save: true) inst = find_or_initialize_by(canvas_id: inst) unless inst.is_a?(self) yield inst inst.save! if save && inst.changed? inst rescue ActiveRecord::RecordNotUnique inst = find_by(canvas_id: inst.canvas_id) yield inst inst.save! if save && inst.changed? inst end end # Call the API and Syncs this model. # Calls the mark_deleted workflow if a 404 is received. # @param [Number] retries Number of retries # @return [Hash] Response Hash from API def sync_from_api(retries: 3, **kwargs) api_response = request_from_api(retries: retries, **kwargs) update_from_api_params!(api_response, **kwargs) api_response rescue *NON_EXISTANT_ERRORS api_mark_deleted save! if changed? nil end # Fetch this model from the API and return the response # @param [Number] retries Number of retries # @return [Hash] Response Hash from API def request_from_api(retries: 3, **kwargs) api_call_with_retry(retries || 3) { blk = api_sync_options[:fetch_from_api] case blk.arity when 1 self.instance_exec(canvas_sync_client, &blk) else self.instance_exec(&blk) end } end # Apply a response Hash from the API to this model's attributes, but do not save # @param [Hash] api_params API-format Hash # @return [self] self def assign_from_api_params(api_params, **kwargs) options = self.api_sync_options api_params = api_params.with_indifferent_access map = options[:field_map] mapped_params = {} if map.present? map.each do |local_name, remote_name| if remote_name.respond_to?(:call) mapped_params[local_name] = self.instance_exec(api_params, &remote_name) elsif api_params.include?(remote_name) mapped_params[local_name] = api_params[remote_name] if remote_name == :id current_value = send("#{local_name}") raise "Mismatched Canvas ID" if current_value.present? && current_value != api_params[remote_name] end end end end apply_block = options[:process_response] if apply_block.present? case apply_block.arity when 1 self.instance_exec(api_params, &apply_block) when 2 self.instance_exec(api_params, mapped_params, &apply_block) end else mapped_params.each do |local_name, val| send("#{local_name}=", val) end end self end # Apply a response Hash from the API to this model's attributes and save if changed? # @param [Hash] api_params API-format Hash # @return [self] self def update_from_api_params(*args) assign_from_api_params(*args) save if changed? end # Apply a response Hash from the API to this model's attributes, and save! if changed? # @param [Hash] api_params API-format Hash # @return [self] self def update_from_api_params!(*args) assign_from_api_params(*args) save! if changed? end def api_sync_options self.class.api_sync_options end private def api_call_with_retry(retries=3) tries ||= retries yield rescue Faraday::ConnectionFailed => e raise e if (tries -= 1).zero? sleep 5 retry end def api_mark_deleted action = api_sync_options[:mark_deleted] case action when Hash assign_attributes(action) when Symbol send(action) when Proc self.instance_exec(&action) end end end end