panda_pal 5.0.0 → 5.2.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +208 -90
- 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 +13 -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 +139 -44
- 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 +64 -8
- 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
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module PandaPal
|
2
|
+
module MiscHelper
|
3
|
+
MigrationClass = Rails.version < '5.0' ? ActiveRecord::Migration : ActiveRecord::Migration[4.2]
|
4
|
+
|
5
|
+
def self.to_boolean(v)
|
6
|
+
if Rails.version < '5.0'
|
7
|
+
ActiveRecord::Type::Boolean.new.type_cast_from_user("0")
|
8
|
+
else
|
9
|
+
ActiveRecord::Type::Boolean.new.deserialize('0')
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
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
|