panda_pal 5.0.0.beta.2 → 5.2.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.
- checksums.yaml +4 -4
- data/README.md +204 -107
- data/app/controllers/panda_pal/lti_controller.rb +0 -18
- data/app/controllers/panda_pal/lti_v1_p0_controller.rb +34 -0
- data/app/controllers/panda_pal/lti_v1_p3_controller.rb +98 -0
- data/app/lib/lti_xml/base_platform.rb +4 -4
- data/app/lib/panda_pal/launch_url_helpers.rb +69 -0
- data/app/lib/panda_pal/lti_jwt_validator.rb +88 -0
- data/app/lib/panda_pal/misc_helper.rb +11 -0
- data/app/models/panda_pal/organization.rb +21 -47
- data/app/models/panda_pal/organization_concerns/settings_validation.rb +127 -0
- data/app/models/panda_pal/organization_concerns/task_scheduling.rb +204 -0
- data/app/models/panda_pal/platform.rb +40 -0
- data/app/views/panda_pal/lti_v1_p3/login.html.erb +1 -0
- data/app/views/panda_pal/partials/_auto_submit_form.html.erb +9 -0
- data/config/dev_lti_key.key +27 -0
- data/config/routes.rb +12 -2
- data/db/migrate/20160412205931_create_panda_pal_organizations.rb +1 -1
- data/db/migrate/20160413135653_create_panda_pal_sessions.rb +1 -1
- data/db/migrate/20160425130344_add_panda_pal_organization_to_session.rb +1 -1
- data/db/migrate/20170106165533_add_salesforce_id_to_organizations.rb +1 -1
- data/db/migrate/20171205183457_encrypt_organization_settings.rb +1 -1
- data/db/migrate/20171205194657_remove_old_organization_settings.rb +8 -3
- data/lib/panda_pal.rb +28 -15
- data/lib/panda_pal/engine.rb +8 -39
- data/lib/panda_pal/helpers.rb +1 -0
- data/lib/panda_pal/helpers/controller_helper.rb +137 -90
- data/lib/panda_pal/helpers/route_helper.rb +8 -8
- data/lib/panda_pal/helpers/secure_headers.rb +79 -0
- data/lib/panda_pal/version.rb +1 -1
- data/panda_pal.gemspec +6 -2
- data/spec/dummy/config/application.rb +7 -1
- data/spec/dummy/config/environments/development.rb +0 -14
- data/spec/dummy/config/environments/production.rb +0 -11
- data/spec/models/panda_pal/organization/settings_validation_spec.rb +175 -0
- data/spec/models/panda_pal/organization/task_scheduling_spec.rb +144 -0
- data/spec/models/panda_pal/organization_spec.rb +0 -89
- data/spec/spec_helper.rb +4 -0
- metadata +66 -12
- data/app/views/panda_pal/lti/iframe_cookie_authorize.html.erb +0 -19
- data/app/views/panda_pal/lti/iframe_cookie_fix.html.erb +0 -12
- data/spec/dummy/config/initializers/assets.rb +0 -11
@@ -0,0 +1,98 @@
|
|
1
|
+
require_dependency "panda_pal/application_controller"
|
2
|
+
|
3
|
+
module PandaPal
|
4
|
+
class LtiV1P3Controller < ApplicationController
|
5
|
+
skip_before_action :verify_authenticity_token
|
6
|
+
|
7
|
+
before_action :validate_launch!, only: [:resource_link_request]
|
8
|
+
around_action :switch_tenant, only: [:resource_link_request]
|
9
|
+
|
10
|
+
def login
|
11
|
+
current_session_data[:lti_oauth_nonce] = SecureRandom.uuid
|
12
|
+
|
13
|
+
@form_action = current_lti_platform.authentication_redirect_url
|
14
|
+
@method = :post
|
15
|
+
@form_data = {
|
16
|
+
scope: 'openid',
|
17
|
+
response_type: 'id_token',
|
18
|
+
response_mode: 'form_post',
|
19
|
+
prompt: 'none',
|
20
|
+
redirect_uri: params[:target_link_uri] || v1p3_resource_link_request_url,
|
21
|
+
client_id: params.require(:client_id),
|
22
|
+
login_hint: params.require(:login_hint),
|
23
|
+
lti_message_hint: params.require(:lti_message_hint),
|
24
|
+
state: current_session.session_key,
|
25
|
+
nonce: current_session_data[:lti_oauth_nonce]
|
26
|
+
}
|
27
|
+
end
|
28
|
+
|
29
|
+
def resource_link_request
|
30
|
+
# Redirect to correct region/env?
|
31
|
+
if params[:launch_type]
|
32
|
+
current_session_data.merge!({
|
33
|
+
lti_version: 'v1p3',
|
34
|
+
lti_launch_placement: params[:launch_type],
|
35
|
+
launch_params: @decoded_lti_jwt,
|
36
|
+
})
|
37
|
+
|
38
|
+
redirect_with_session_to(:"#{LaunchUrlHelpers.launch_route(params[:launch_type])}_url", route_context: main_app)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def tool_config
|
43
|
+
if PandaPal.lti_environments.empty?
|
44
|
+
render plain: 'Domains must be set in lti_environments'
|
45
|
+
return
|
46
|
+
end
|
47
|
+
|
48
|
+
platform = PandaPal.lti_options.delete(:platform) || 'canvas.instructure.com'
|
49
|
+
request_url = "#{request.scheme}://#{request.host_with_port}"
|
50
|
+
parsed_request_url = URI.parse(request_url)
|
51
|
+
|
52
|
+
mapped_placements = PandaPal.lti_paths.map do |k, opts|
|
53
|
+
opts = opts.dup
|
54
|
+
opts.delete(:route_helper_key)
|
55
|
+
opts.merge!({
|
56
|
+
placement: k,
|
57
|
+
target_link_uri: LaunchUrlHelpers.absolute_launch_url(k.to_sym, host: parsed_request_url, launch_handler: v1p3_resource_link_request_path),
|
58
|
+
})
|
59
|
+
opts
|
60
|
+
end
|
61
|
+
|
62
|
+
config_json = {
|
63
|
+
title: PandaPal.lti_options[:title],
|
64
|
+
scopes: [],
|
65
|
+
public_jwk_url: v1p3_public_jwks_url,
|
66
|
+
description: PandaPal.lti_options[:description] || 'PandaPal LTI',
|
67
|
+
target_link_uri: v1p3_resource_link_request_url, #app_url(:resource_link_request, request),
|
68
|
+
oidc_initiation_url: v1p3_oidc_login_url,
|
69
|
+
extensions: [{
|
70
|
+
platform: platform,
|
71
|
+
privacy_level: "public",
|
72
|
+
settings: {
|
73
|
+
placements: mapped_placements,
|
74
|
+
environments: PandaPal.lti_environments,
|
75
|
+
},
|
76
|
+
}],
|
77
|
+
custom_fields: PandaPal.lti_custom_params, # PandaPal.lti_options[:custom_fields],
|
78
|
+
}
|
79
|
+
|
80
|
+
render json: config_json
|
81
|
+
end
|
82
|
+
|
83
|
+
def public_jwks
|
84
|
+
render json: {
|
85
|
+
keys: [JWT::JWK.new(PandaPal.lti_private_key).export]
|
86
|
+
}
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def auth_redirect_query
|
92
|
+
return unless params[:target_link_uri]&.include? 'platform_redirect_url='
|
93
|
+
|
94
|
+
platform_redirect_url = Rack::Utils.parse_query(URI(params[:target_link_uri]).query)&.dig('platform_redirect_url')
|
95
|
+
"?platform_redirect_url=#{platform_redirect_url}"
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -76,7 +76,7 @@ module LtiXml
|
|
76
76
|
|
77
77
|
def add_lti_nav
|
78
78
|
PandaPal.lti_paths.each do |k, v|
|
79
|
-
@tc.set_ext_param(platform, k.to_sym, ext_params(v))
|
79
|
+
@tc.set_ext_param(platform, k.to_sym, ext_params(v, k))
|
80
80
|
end
|
81
81
|
end
|
82
82
|
|
@@ -84,9 +84,9 @@ module LtiXml
|
|
84
84
|
@tc.set_ext_param(platform, :environments, PandaPal.lti_environments)
|
85
85
|
end
|
86
86
|
|
87
|
-
def ext_params(options)
|
88
|
-
|
89
|
-
options[:url] =
|
87
|
+
def ext_params(options, k)
|
88
|
+
options.delete(:route_helper_key)
|
89
|
+
options[:url] = PandaPal::LaunchUrlHelpers.absolute_launch_url(k.to_sym, host: parsed_request_url, launch_handler: nil)
|
90
90
|
options
|
91
91
|
end
|
92
92
|
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
module PandaPal
|
2
|
+
module LaunchUrlHelpers
|
3
|
+
def self.absolute_launch_url(launch_type, host:, launch_handler: nil)
|
4
|
+
opts = PandaPal.lti_paths[launch_type]
|
5
|
+
final_url = launch_url(opts, launch_type: launch_type)
|
6
|
+
|
7
|
+
is_direct = opts[:route_helper_key].present? || !launch_handler.present?
|
8
|
+
|
9
|
+
if is_direct
|
10
|
+
return final_url if URI.parse(final_url).absolute?
|
11
|
+
return [host.to_s, final_url].join
|
12
|
+
else
|
13
|
+
launch_handler = resolve_route(launch_handler) if launch_handler.is_a?(Symbol)
|
14
|
+
return add_url_params([host.to_s, launch_handler].join, {
|
15
|
+
launch_type: launch_type,
|
16
|
+
})
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.launch_url(opts, launch_type: nil)
|
21
|
+
url = launch_route(opts, launch_type: launch_type)
|
22
|
+
url = resolve_url_symbol(url) if url.is_a?(Symbol)
|
23
|
+
url
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.launch_route(opts, launch_type: nil)
|
27
|
+
if opts.is_a?(Symbol) || opts.is_a?(String)
|
28
|
+
launch_type = opts.to_sym
|
29
|
+
opts = PandaPal.lti_paths[launch_type]
|
30
|
+
end
|
31
|
+
|
32
|
+
if opts[:route_helper_key]
|
33
|
+
opts[:route_helper_key].to_sym
|
34
|
+
else
|
35
|
+
opts[:url] ||= launch_type
|
36
|
+
opts[:url]
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.resolve_url_symbol(sym, **opts)
|
41
|
+
sym = :"#{sym}_url" unless sym.to_s.ends_with?('_url')
|
42
|
+
opts[:only_path] = true unless opts.key?(:only_path)
|
43
|
+
resolve_route(:"MainApp/#{sym}", **opts)
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.add_url_params(url, params)
|
47
|
+
uri = URI(url)
|
48
|
+
decoded_params = URI.decode_www_form(uri.query || "")
|
49
|
+
params.each do |k, v|
|
50
|
+
decoded_params << [k, v]
|
51
|
+
end
|
52
|
+
uri.query = URI.encode_www_form(decoded_params)
|
53
|
+
uri.to_s
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def self.resolve_route(key, *arguments, engine: 'PandaPal', **kwargs)
|
59
|
+
return key if key.is_a?(String)
|
60
|
+
|
61
|
+
key_bits = key.to_s.split('/')
|
62
|
+
key_bits.prepend(engine) if key_bits.count == 1
|
63
|
+
key_bits[0] = key_bits[0].classify
|
64
|
+
|
65
|
+
engine = key_bits[0] == 'MainApp' ? Rails.application : (key_bits[0].constantize)::Engine
|
66
|
+
engine.routes.url_helpers.send(key_bits[1], *arguments, **kwargs)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module PandaPal
|
2
|
+
class LtiJwtValidator
|
3
|
+
attr_reader :errors
|
4
|
+
|
5
|
+
def initialize(jwt, client_id)
|
6
|
+
@jwt = jwt
|
7
|
+
@client_id = client_id
|
8
|
+
@errors = []
|
9
|
+
end
|
10
|
+
|
11
|
+
def valid?
|
12
|
+
verify_audience(@jwt)
|
13
|
+
verify_sub(@jwt)
|
14
|
+
verify_nonce(@jwt)
|
15
|
+
verify_issued_at(@jwt)
|
16
|
+
verify_expiration(@jwt)
|
17
|
+
errors.empty?
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def verify_audience(jwt)
|
23
|
+
aud = jwt[:aud]
|
24
|
+
aud_array = [*aud]
|
25
|
+
errors << 'Audience must be a string or Array of strings.' unless aud_array.all? { |a| a.is_a? String }
|
26
|
+
if jwt.key? :azp
|
27
|
+
verify_azp(aud, jwt[:azp])
|
28
|
+
else
|
29
|
+
errors << 'Audience not found' unless public_key_matches_one_of_client_ids(aud)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def verify_azp(aud, azp)
|
34
|
+
errors << 'Audience does not contain/match Authorized Party' unless azp_in_aud(aud, azp)
|
35
|
+
errors << 'Audience does not match Platform client_id' unless public_key_matches_one_of_client_ids(aud)
|
36
|
+
end
|
37
|
+
|
38
|
+
def verify_sub(jwt)
|
39
|
+
errors << 'Subject not present' if jwt[:sub].blank?
|
40
|
+
end
|
41
|
+
|
42
|
+
def verify_issued_at(jwt)
|
43
|
+
lower_bound = issued_at_lower_bound
|
44
|
+
errors << "Issued at of #{jwt[:iat]} not between #{lower_bound.to_i} and #{Time.zone.now.to_i}" unless Time.zone.at(
|
45
|
+
jwt[:iat]
|
46
|
+
).between?(
|
47
|
+
lower_bound, Time.zone.now
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
def verify_expiration(jwt)
|
52
|
+
now = Time.zone.now
|
53
|
+
errors << "Expiration time of #{jwt[:exp]} before #{now.to_i}" unless Time.zone.at(jwt[:exp]) > Time.zone.now
|
54
|
+
end
|
55
|
+
|
56
|
+
def verify_nonce(jwt)
|
57
|
+
unless jwt[:nonce].present?
|
58
|
+
errors << 'Nonce not present'
|
59
|
+
return
|
60
|
+
end
|
61
|
+
|
62
|
+
return unless defined?(Redis) && Redis.current
|
63
|
+
|
64
|
+
cache_key = "nonces/#{jwt[:nonce]}"
|
65
|
+
if Redis.current.get(cache_key)
|
66
|
+
errors << 'Nonce has been used already'
|
67
|
+
else
|
68
|
+
Redis.current.set(cache_key, Time.zone.now.to_i, ex: 5.minutes)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def azp_in_aud(aud, azp)
|
73
|
+
if aud.is_a? Array
|
74
|
+
aud.include? azp
|
75
|
+
else
|
76
|
+
aud == azp
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def issued_at_lower_bound
|
81
|
+
ENV.key?('issued_at_minutes_ago') ? ENV['issued_at_minutes_ago'].minutes.ago : 10.minutes.ago
|
82
|
+
end
|
83
|
+
|
84
|
+
def public_key_matches_one_of_client_ids(aud)
|
85
|
+
(Array(aud) & Array(@client_id)).any?
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -1,24 +1,33 @@
|
|
1
1
|
module PandaPal
|
2
|
+
module OrganizationConcerns; end
|
2
3
|
|
3
4
|
class Organization < ActiveRecord::Base
|
4
|
-
|
5
|
+
include OrganizationConcerns::SettingsValidation
|
6
|
+
include OrganizationConcerns::TaskScheduling if defined?(Sidekiq.schedule)
|
7
|
+
|
8
|
+
serialize :settings, Hash
|
5
9
|
attr_encrypted :settings, marshal: true, key: :encryption_key
|
6
10
|
before_save {|a| a.settings = a.settings} # this is a hacky work-around to a bug where attr_encrypted is not saving settings in place
|
11
|
+
|
7
12
|
validates :key, uniqueness: { case_sensitive: false }, presence: true
|
8
13
|
validates :secret, presence: true
|
9
14
|
validates :name, uniqueness: { case_sensitive: false }, presence: true, format: { with: /\A[a-z0-9_]+\z/i }
|
10
15
|
validates :canvas_account_id, presence: true
|
11
16
|
validates :salesforce_id, presence: true, uniqueness: true
|
12
|
-
|
17
|
+
|
13
18
|
after_create :create_schema
|
14
19
|
after_commit :destroy_schema, on: :destroy
|
15
20
|
|
21
|
+
if defined?(scheduled_task)
|
22
|
+
scheduled_task '0 0 3 * * *', :clean_old_sessions do
|
23
|
+
PandaPal::Session.where(panda_pal_organization: self).where('updated_at < ?', 1.week.ago).delete_all
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
16
27
|
before_validation on: [:update] do
|
17
28
|
errors.add(:name, 'should not be changed after creation') if name_changed?
|
18
29
|
end
|
19
30
|
|
20
|
-
serialize :settings, Hash
|
21
|
-
|
22
31
|
def encryption_key
|
23
32
|
# production environment might not have loaded secret_key_base yet.
|
24
33
|
# In that case, just read it from env.
|
@@ -29,6 +38,14 @@ module PandaPal
|
|
29
38
|
end
|
30
39
|
end
|
31
40
|
|
41
|
+
def switch_tenant(&block)
|
42
|
+
if block_given?
|
43
|
+
Apartment::Tenant.switch(name, &block)
|
44
|
+
else
|
45
|
+
Apartment::Tenant.switch!(name)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
32
49
|
private
|
33
50
|
|
34
51
|
def create_schema
|
@@ -38,48 +55,5 @@ module PandaPal
|
|
38
55
|
def destroy_schema
|
39
56
|
Apartment::Tenant.drop name
|
40
57
|
end
|
41
|
-
|
42
|
-
def validate_settings
|
43
|
-
record = self
|
44
|
-
if PandaPal.lti_options && PandaPal.lti_options[:settings_structure]
|
45
|
-
validate_level(record, PandaPal.lti_options[:settings_structure], record.settings, [])
|
46
|
-
end
|
47
|
-
end
|
48
|
-
def validate_level(record, expectation, reality, previous_keys)
|
49
|
-
# Verify that the data elements at this level conform to requirements.
|
50
|
-
if expectation
|
51
|
-
expectation.each do |key, value|
|
52
|
-
is_required = expectation[key].try(:delete, :is_required)
|
53
|
-
data_type = expectation[key].try(:delete, :data_type)
|
54
|
-
value = reality.is_a?(Hash) ? reality.dig(key) : reality
|
55
|
-
|
56
|
-
if is_required && !value
|
57
|
-
record.errors[:settings] << "PandaPal::Organization.settings requires key [:#{previous_keys.push(key).join("][:")}]. It was not found."
|
58
|
-
end
|
59
|
-
if data_type && value
|
60
|
-
if value.class.to_s != data_type
|
61
|
-
record.errors[:settings] << "PandaPal::Organization.settings expected key [:#{previous_keys.push(key).join("][:")}] to be #{data_type} but it was instead #{value.class}."
|
62
|
-
end
|
63
|
-
end
|
64
|
-
end
|
65
|
-
end
|
66
|
-
# Verify that anything that is in the real settings has an expectation.
|
67
|
-
if reality
|
68
|
-
if reality.is_a? Hash
|
69
|
-
reality.each do |key, value|
|
70
|
-
was_expected = expectation.has_key?(key)
|
71
|
-
if !was_expected
|
72
|
-
record.errors[:settings] << "PandaPal::Organization.settings had unexpected key: #{key}. If settings have expanded please update your lti_options accordingly."
|
73
|
-
end
|
74
|
-
end
|
75
|
-
end
|
76
|
-
end
|
77
|
-
# Recursively check out any children settings as well.
|
78
|
-
if expectation
|
79
|
-
expectation.each do |key, value|
|
80
|
-
validate_level(record, expectation[key], (reality.is_a?(Hash) ? reality.dig(key) : nil), previous_keys.deep_dup.push(key))
|
81
|
-
end
|
82
|
-
end
|
83
|
-
end
|
84
58
|
end
|
85
59
|
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
module PandaPal
|
2
|
+
module OrganizationConcerns
|
3
|
+
module SettingsValidation
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
included do
|
7
|
+
validate :validate_settings
|
8
|
+
end
|
9
|
+
|
10
|
+
class_methods do
|
11
|
+
def settings_structure
|
12
|
+
if PandaPal.lti_options&.[](:settings_structure).present?
|
13
|
+
normalize_settings_structure(PandaPal.lti_options[:settings_structure])
|
14
|
+
else
|
15
|
+
{
|
16
|
+
type: Hash,
|
17
|
+
allow_additional: true,
|
18
|
+
properties: {},
|
19
|
+
}
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def normalize_settings_structure(struc)
|
24
|
+
return {} unless struc.present?
|
25
|
+
return struc if struc[:properties] || struc[:type] || struc.key?(:required)
|
26
|
+
|
27
|
+
struc = struc.dup
|
28
|
+
nstruc = {}
|
29
|
+
|
30
|
+
nstruc[:type] = struc.delete(:data_type) if struc.key?(:data_type)
|
31
|
+
nstruc[:required] = struc.delete(:is_required) if struc.key?(:is_required)
|
32
|
+
nstruc[:properties] = struc.map { |k, sub| [k, normalize_settings_structure(sub)] }.to_h if struc.present?
|
33
|
+
|
34
|
+
nstruc
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def settings_structure
|
39
|
+
self.class.settings_structure
|
40
|
+
end
|
41
|
+
|
42
|
+
def validate_settings
|
43
|
+
validate_settings_level(settings || {}, settings_structure).each do |err|
|
44
|
+
errors[:settings] << err
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def validate_settings_level(settings, spec, path: [], errors: [])
|
51
|
+
human_path = "[:#{path.join('][:')}]"
|
52
|
+
|
53
|
+
if settings.nil?
|
54
|
+
errors << "Entry #{human_path} is required" if spec[:required]
|
55
|
+
return errors
|
56
|
+
end
|
57
|
+
|
58
|
+
if spec[:type]
|
59
|
+
norm_types = Array(spec[:type]).map do |t|
|
60
|
+
if [:bool, :boolean, 'Bool', 'Boolean'].include?(t)
|
61
|
+
'Boolean'
|
62
|
+
elsif t.is_a?(String)
|
63
|
+
t.constantize
|
64
|
+
else
|
65
|
+
t
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
any_match = norm_types.any? do |t|
|
70
|
+
if t == 'Boolean'
|
71
|
+
settings == true || settings == false
|
72
|
+
else
|
73
|
+
settings.is_a?(t)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
unless any_match
|
78
|
+
errors << "Expected #{human_path} to be a #{spec[:type]}. Was a #{settings.class.to_s}"
|
79
|
+
return errors
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
if spec[:validate].present?
|
84
|
+
val_errors = []
|
85
|
+
if spec[:validate].is_a?(Symbol)
|
86
|
+
proc_result = send(spec[:validate], settings, spec, path: path, errors: val_errors)
|
87
|
+
elsif spec[:validate].is_a?(String)
|
88
|
+
split_val = spec[:validate].split?('.')
|
89
|
+
split_val << 'validate_settings' if split_val.count == 1
|
90
|
+
resolved_module = split_val[0].constantize
|
91
|
+
proc_result = resolved_module.send(split_val[1].to_sym, settings, spec, path: path, errors: val_errors)
|
92
|
+
elsif spec[:validate].is_a?(Proc)
|
93
|
+
proc_result = instance_exec(settings, spec, path: path, errors: val_errors, &spec[:validate])
|
94
|
+
end
|
95
|
+
val_errors << proc_result unless val_errors.present? || proc_result == val_errors
|
96
|
+
val_errors = val_errors.flatten.uniq.compact.map do |ve|
|
97
|
+
ve.gsub('<path>', human_path)
|
98
|
+
end
|
99
|
+
errors.concat(val_errors)
|
100
|
+
end
|
101
|
+
|
102
|
+
if settings.is_a?(Hash)
|
103
|
+
if spec[:properties] != nil
|
104
|
+
spec[:properties].each do |key, pspec|
|
105
|
+
validate_settings_level(settings[key], pspec, path: [*path, key], errors: errors)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
if spec[:properties] != nil || spec[:allow_additional] != nil
|
110
|
+
extra_keys = settings.keys - (spec[:properties]&.keys || [])
|
111
|
+
if extra_keys.present?
|
112
|
+
if spec[:allow_additional].is_a?(Hash)
|
113
|
+
extra_keys.each do |key|
|
114
|
+
validate_settings_level(settings[key], spec[:allow_additional], path: [*path, key], errors: errors)
|
115
|
+
end
|
116
|
+
elsif !spec[:allow_additional]
|
117
|
+
errors << "Did not expect #{human_path} to contain [#{extra_keys.join(', ')}]"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
errors
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|