module PandaPal module OrganizationConcerns module SettingsValidation extend ActiveSupport::Concern included do validate :validate_settings end class_methods do def define_setting(*args, &blk) @_injected_settings_definitions ||= [] @_injected_settings_definitions << { args: args, block: blk, } end def settings_structure struc = if PandaPal.lti_options&.[](:settings_structure).present? normalize_settings_structure(PandaPal.lti_options[:settings_structure]) else { type: Hash, allow_additional: true, properties: {}, } end (@_injected_settings_definitions || []).each do |sub| args = [*sub[:args]] path = args.shift || [] path = path.split('.') if path.is_a?(String) path = Array(path) if path.present? key = path.pop root = struc path.each do |p| root = root[:properties][p.to_sym] end if sub[:block] root[:properties][key.to_sym] = sub[:block].call else root[:properties][key.to_sym] = args.shift end else sub[:block].call(struc) end end struc end def normalize_settings_structure(struc) return {} unless struc.present? return struc if struc[:properties] || struc[:type] || struc.key?(:required) struc = struc.dup nstruc = {} nstruc[:type] = struc.delete(:data_type) if struc.key?(:data_type) nstruc[:required] = struc.delete(:is_required) if struc.key?(:is_required) nstruc[:properties] = struc.map { |k, sub| [k, normalize_settings_structure(sub)] }.to_h if struc.present? nstruc[:type] = "Hash" if !nstruc[:type] && nstruc.key?(:properties) nstruc end def remove_undeclared_settings(value, setting: settings_structure) if setting[:type] == "Hash" value.dup.tap do |value| value.keys.each do |key| if setting[:properties].key?(key.to_sym) value[key] = remove_undeclared_settings(value[key], setting: setting[:properties][key.to_sym]) elsif !setting[:allow_additional] value.delete(key) end end end else value end end def generate_settings_ruby(indent: 0, setting: settings_structure, value: { }, exclude_extra: false) builder = ConsoleHelpers::CodeBuilder.new(indent: indent) value = setting[:default] || setting.dig(:json_schema, :default) if value == :not_given if setting[:type] == "Hash" builder << "{" builder << "\n" builder.indent! setting[:properties].each do |key, sub| if sub[:description] builder << sub[:description].lines.map{|l| "# #{l}"} builder.ensure_line end sub_val = value&.key?(key) ? value[key] : :not_given commented = sub_val == :not_given && !sub[:required] builder.indent!("# ") if commented builder << "#{key}: " builder << generate_settings_ruby(setting: sub, value: sub_val) builder << ",\n" builder.dedent! if commented end # If we're editing an existing org, we may have extra keys that aren't in the schema # But if we're creating a new org, we don't want to include undocumented entries from pandapalrc.yml unless exclude_extra extra_keys = (value&.keys || []) - setting[:properties].keys.map(&:to_s) extra_keys.each do |key| builder << "# Undocumented\n" unless setting[:allow_additional] builder << "#{key}: #{value[key].inspect},\n" end end builder.dedent! builder << "}" elsif setting[:type] == "Array" builder << "#{value&.inspect || '[]'}" elsif setting[:type] == "Integer" builder << "#{value || 0}" elsif setting[:type] == "Numeric" builder << "#{value || 1.0}" else setting[:type] == "String" builder << "\"#{value}\"" end builder.to_s end end def settings_structure self.class.settings_structure end def validate_settings validate_settings_level(settings || {}, settings_structure).each do |err| errors.add(:settings, err) end end private def validate_settings_level(settings, spec, path: [], errors: []) human_path = "[:#{path.join('][:')}]" if settings.nil? errors << "Entry #{human_path} is required" if spec[:required] return errors end if spec[:type] norm_types = Array(spec[:type]).map do |t| if [:bool, :boolean, 'Bool', 'Boolean'].include?(t) 'Boolean' elsif t.is_a?(String) t.constantize else t end end any_match = norm_types.any? do |t| if t == 'Boolean' settings == true || settings == false elsif t.is_a?(Class) settings.is_a?(t) end end unless any_match errors << "Expected #{human_path} to be a #{spec[:type]}. Was a #{settings.class.to_s}" return errors end end if spec[:validate].present? val_errors = [] if spec[:validate].is_a?(Symbol) proc_result = send(spec[:validate], settings, spec, path: path, errors: val_errors) elsif spec[:validate].is_a?(String) split_val = spec[:validate].split?('.') split_val << 'validate_settings' if split_val.count == 1 resolved_module = split_val[0].constantize proc_result = resolved_module.send(split_val[1].to_sym, settings, spec, path: path, errors: val_errors) elsif spec[:validate].is_a?(Proc) proc_result = instance_exec(settings, spec, path: path, errors: val_errors, &spec[:validate]) end val_errors << proc_result unless val_errors.present? || proc_result == val_errors val_errors = val_errors.flatten.uniq.compact.map do |ve| ve.gsub('<path>', human_path) end errors.concat(val_errors) end if settings.is_a?(Array) if spec[:length].is_a?(Range) errors << "#{human_path} should contain #{spec[:length]} items" unless spec[:length].include?(settings.count) elsif spec[:length].is_a?(Numeric) errors << "#{human_path} should contain exactly #{spec[:length]} items" unless spec[:length] == settings.count end if spec[:item] != nil settings.each_with_index do |value, i| validate_settings_level(settings[i], spec[:item], path: [*path, i], errors: errors) end end end if settings.is_a?(Hash) if spec[:properties] != nil spec[:properties].each do |key, pspec| validate_settings_level(settings[key], pspec, path: [*path, key], errors: errors) end end if spec[:properties] != nil || spec[:allow_additional] != nil set_keys = settings.keys expected_keys = spec[:properties]&.keys || [] expected_keys = expected_keys.map(&:to_s) if settings.is_a?(HashWithIndifferentAccess) extra_keys = set_keys - expected_keys if extra_keys.present? if spec[:allow_additional].is_a?(Hash) extra_keys.each do |key| validate_settings_level(settings[key], spec[:allow_additional], path: [*path, key], errors: errors) end elsif !spec[:allow_additional] errors << "Did not expect #{human_path} to contain [#{extra_keys.join(', ')}]" end end end end errors end end end end