contextual_config 0.1.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 +7 -0
- data/.claude/settings.local.json +14 -0
- data/.rspec +3 -0
- data/CLAUDE.md +66 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +295 -0
- data/LICENSE.txt +21 -0
- data/README.md +782 -0
- data/Rakefile +10 -0
- data/contextual_config.gemspec +43 -0
- data/lib/contextual_config/concern/configurable.rb +112 -0
- data/lib/contextual_config/concern/lookupable.rb +129 -0
- data/lib/contextual_config/concern/schema_driven_validation.rb +152 -0
- data/lib/contextual_config/configuration.rb +77 -0
- data/lib/contextual_config/generators.rb +4 -0
- data/lib/contextual_config/module_registry.rb +144 -0
- data/lib/contextual_config/services/contextual_matcher.rb +201 -0
- data/lib/contextual_config/version.rb +3 -0
- data/lib/contextual_config.rb +71 -0
- data/lib/generators/contextual_config/configurable_table/USAGE +33 -0
- data/lib/generators/contextual_config/configurable_table/configurable_table_generator.rb +68 -0
- data/lib/generators/contextual_config/configurable_table/templates/migration.rb.tt +52 -0
- metadata +194 -0
data/Rakefile
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require_relative 'lib/contextual_config/version'
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = 'contextual_config'
|
|
5
|
+
spec.version = ContextualConfig::VERSION
|
|
6
|
+
spec.authors = ['bazinga012']
|
|
7
|
+
spec.email = ['vishal18593@gmail.com']
|
|
8
|
+
|
|
9
|
+
spec.summary = 'A Ruby gem for context-aware configuration management'
|
|
10
|
+
spec.description = 'ContextualConfig provides a flexible framework for managing configurations that can be applied based on contextual rules, priorities, and scoping. Perfect for complex applications requiring dynamic configuration resolution.'
|
|
11
|
+
spec.homepage = 'https://github.com/bazinga012/contextual_config'
|
|
12
|
+
spec.license = 'MIT'
|
|
13
|
+
spec.required_ruby_version = '>= 3.0.0' # rubocop:disable Gemspec/RequiredRubyVersion
|
|
14
|
+
|
|
15
|
+
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
|
16
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
17
|
+
spec.metadata['source_code_uri'] = 'https://github.com/bazinga012/contextual_config'
|
|
18
|
+
spec.metadata['changelog_uri'] = 'https://github.com/bazinga012/contextual_config/blob/main/CHANGELOG.md'
|
|
19
|
+
|
|
20
|
+
# Specify which files should be added to the gem when it is released.
|
|
21
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
22
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
23
|
+
(f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
spec.bindir = 'exe'
|
|
27
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
28
|
+
spec.require_paths = ['lib']
|
|
29
|
+
|
|
30
|
+
# Dependencies
|
|
31
|
+
spec.add_dependency('activerecord', '>= 6.0')
|
|
32
|
+
spec.add_dependency('activesupport', '>= 6.0')
|
|
33
|
+
spec.add_dependency('json-schema', '~> 5.1')
|
|
34
|
+
|
|
35
|
+
# Development dependencies
|
|
36
|
+
spec.add_development_dependency('rails', '>= 6.0')
|
|
37
|
+
spec.add_development_dependency('rspec', '~> 3.0')
|
|
38
|
+
spec.add_development_dependency('rubocop', '~> 1.0')
|
|
39
|
+
spec.add_development_dependency('rubocop-rails', '~> 2.0')
|
|
40
|
+
spec.add_development_dependency('rubocop-rspec', '~> 3.0')
|
|
41
|
+
spec.add_development_dependency('sqlite3', '>= 2.1')
|
|
42
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
43
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
require 'active_support/concern'
|
|
2
|
+
|
|
3
|
+
module ContextualConfig
|
|
4
|
+
module Concern
|
|
5
|
+
module Configurable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
# --- Class Methods ---
|
|
9
|
+
included do
|
|
10
|
+
# --- Column Expectations ---
|
|
11
|
+
# This concern implicitly expects the including model's table to have columns like:
|
|
12
|
+
# - key:string
|
|
13
|
+
# - config_data:jsonb
|
|
14
|
+
# - scoping_rules:jsonb
|
|
15
|
+
# - priority:integer (default should be a higher number for lower priority)
|
|
16
|
+
# - is_active:boolean (default should be true)
|
|
17
|
+
# - type:string (if STI is to be used on the including model)
|
|
18
|
+
# - created_at:datetime
|
|
19
|
+
# - updated_at:datetime
|
|
20
|
+
|
|
21
|
+
# --- Validations ---
|
|
22
|
+
# Ensures that a 'key' is always present for any configuration.
|
|
23
|
+
validates :key, presence: true
|
|
24
|
+
|
|
25
|
+
# NOTE: Uniqueness validation for 'key' is often more complex.
|
|
26
|
+
# It might need to be scoped by 'type' (if using STI directly on the including model)
|
|
27
|
+
# or by other module-specific identifiers if this concern is used by various
|
|
28
|
+
# top-level configuration models in different tables.
|
|
29
|
+
# Example for STI:
|
|
30
|
+
# validates :key, uniqueness: { scope: :type, message: "must be unique within its type" }
|
|
31
|
+
# This specific validation is commented out here as its exact nature depends on how
|
|
32
|
+
# the including model is structured (STI parent vs. standalone model).
|
|
33
|
+
# It's often better to add this uniqueness validation directly in the consuming model
|
|
34
|
+
# where the scope is clearer.
|
|
35
|
+
|
|
36
|
+
# Ensures 'is_active' is either true or false.
|
|
37
|
+
validates :is_active, inclusion: { in: [true, false], message: 'is not included in the list' }
|
|
38
|
+
|
|
39
|
+
# Ensures 'priority' is an integer.
|
|
40
|
+
validates :priority, numericality: { only_integer: true, message: 'is not a number' }
|
|
41
|
+
|
|
42
|
+
# --- Scopes ---
|
|
43
|
+
|
|
44
|
+
# Scope to retrieve only active configurations.
|
|
45
|
+
# Usage: YourModel.active
|
|
46
|
+
scope :active, -> { where(is_active: true) }
|
|
47
|
+
|
|
48
|
+
# Scope to order configurations by priority.
|
|
49
|
+
# Lower numbers indicate higher priority. Secondary sort by ID for deterministic ordering.
|
|
50
|
+
# Usage: YourModel.order_by_priority
|
|
51
|
+
scope :order_by_priority, -> { order(priority: :asc, id: :asc) } # 'asc' for priority means 1 is higher than 10
|
|
52
|
+
|
|
53
|
+
# --- Default Values ---
|
|
54
|
+
# It's generally recommended to set database-level defaults for `is_active` (true)
|
|
55
|
+
# and `priority` (e.g., a high number like 100 for low priority).
|
|
56
|
+
# If not set at DB level, you can use `after_initialize` here, but DB defaults are safer.
|
|
57
|
+
after_initialize :set_default_values, if: :new_record?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# --- Instance Methods ---
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def set_default_values
|
|
65
|
+
# Only set defaults if the model is completely new (not loaded from DB and no explicit values set)
|
|
66
|
+
if new_record? && !attributes_before_type_cast.key?('is_active')
|
|
67
|
+
self.is_active = true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Override database default priority with module-specific priority if available
|
|
71
|
+
set_module_default_priority if should_use_module_default_priority?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if we should use module default priority
|
|
75
|
+
def should_use_module_default_priority?
|
|
76
|
+
# Use module default if:
|
|
77
|
+
# 1. Priority is nil, OR
|
|
78
|
+
# 2. Priority equals the database default AND we have a module config with different default
|
|
79
|
+
return true if priority.nil?
|
|
80
|
+
|
|
81
|
+
db_default = self.class.column_defaults['priority']&.to_i
|
|
82
|
+
module_config = ContextualConfig.registry.configs_for_model(self.class).first
|
|
83
|
+
|
|
84
|
+
priority == db_default && module_config && module_config.default_priority != db_default
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Set priority from module configuration or global configuration
|
|
88
|
+
def set_module_default_priority
|
|
89
|
+
# Try to get priority from module registry
|
|
90
|
+
module_config = ContextualConfig.registry.configs_for_model(self.class).first
|
|
91
|
+
self.priority = if module_config
|
|
92
|
+
module_config.default_priority
|
|
93
|
+
else
|
|
94
|
+
# Fall back to global configuration
|
|
95
|
+
ContextualConfig.configuration.default_priority
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
public
|
|
100
|
+
|
|
101
|
+
# Helper to quickly check if a configuration is currently active
|
|
102
|
+
def active?
|
|
103
|
+
is_active
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Get the module configuration for this model if available
|
|
107
|
+
def module_configuration
|
|
108
|
+
ContextualConfig.registry.configs_for_model(self.class).first
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
require 'active_support/concern'
|
|
2
|
+
require 'digest'
|
|
3
|
+
|
|
4
|
+
module ContextualConfig
|
|
5
|
+
module Concern
|
|
6
|
+
module Lookupable
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
# --- Class Methods ---
|
|
10
|
+
module ClassMethods
|
|
11
|
+
# Finds the single most applicable configuration based on a key and context.
|
|
12
|
+
#
|
|
13
|
+
# @param key [String, Symbol] The specific key of the configuration to look for.
|
|
14
|
+
# @param context [Hash] A hash representing the current context (e.g., { employee_id: 'x', current_date: Date.today }).
|
|
15
|
+
# @param fetch_all_of_key [Boolean] If true, fetches all configs with the given key before matching.
|
|
16
|
+
# If false (default), assumes key is unique within the relevant scope (e.g., STI type)
|
|
17
|
+
# and fetches only those.
|
|
18
|
+
# @return [ActiveRecord::Base, nil] The instance of the best matching configuration, or nil if no match.
|
|
19
|
+
def find_applicable_config(key:, context:, fetch_all_of_key: false) # rubocop:disable Lint/UnusedMethodArgument
|
|
20
|
+
# Check cache first if enabled
|
|
21
|
+
if ContextualConfig.configuration.cache_enabled?
|
|
22
|
+
begin
|
|
23
|
+
cache_key = generate_cache_key(key, context)
|
|
24
|
+
cached_result = ContextualConfig.configuration.cache_store.read(cache_key)
|
|
25
|
+
if cached_result
|
|
26
|
+
log_lookup_attempt(key, context, 'cache_hit')
|
|
27
|
+
return cached_result
|
|
28
|
+
end
|
|
29
|
+
rescue => e
|
|
30
|
+
# Log cache error but continue with database lookup
|
|
31
|
+
log_cache_error('read', e)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# `self` here refers to the specific class this method is called on
|
|
36
|
+
# (e.g., Finance::TaxConfig or a more generic YourTeam::Configuration).
|
|
37
|
+
|
|
38
|
+
# Start with active configurations, ordered by priority (higher priority first).
|
|
39
|
+
# The `Configurable` concern is expected to provide `active` and `order_by_priority` scopes.
|
|
40
|
+
unless self.respond_to?(:active) && self.respond_to?(:order_by_priority)
|
|
41
|
+
raise(NoMethodError, "#{self.name} must include ContextualConfig::Concern::Configurable to use Lookupable.")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
log_lookup_attempt(key, context, 'database_lookup')
|
|
45
|
+
|
|
46
|
+
candidates = self.active.where(key: key.to_s).order_by_priority
|
|
47
|
+
|
|
48
|
+
# Delegate to the ContextualMatcher service to find the best match from the candidates.
|
|
49
|
+
# The service will handle the logic of iterating through candidates, evaluating scoping_rules,
|
|
50
|
+
# calculating specificity, and applying priority rules.
|
|
51
|
+
result = ContextualConfig::Services::ContextualMatcher.find_best_match(
|
|
52
|
+
candidates: candidates,
|
|
53
|
+
context: context
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Cache the result if caching is enabled
|
|
57
|
+
if ContextualConfig.configuration.cache_enabled? && result
|
|
58
|
+
begin
|
|
59
|
+
cache_key = generate_cache_key(key, context)
|
|
60
|
+
ContextualConfig.configuration.cache_store.write(
|
|
61
|
+
cache_key,
|
|
62
|
+
result,
|
|
63
|
+
expires_in: ContextualConfig.configuration.cache_ttl
|
|
64
|
+
)
|
|
65
|
+
log_lookup_attempt(key, context, 'cache_stored')
|
|
66
|
+
rescue => e
|
|
67
|
+
# Log cache error but don't fail the operation
|
|
68
|
+
log_cache_error('write', e)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
result
|
|
73
|
+
# Return the full record, the caller can decide to use .config_data or other attributes
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Finds all configurations that apply to a given context, without filtering by a specific key.
|
|
77
|
+
# Useful if multiple configurations of the same type (but different keys or scopes) can apply simultaneously,
|
|
78
|
+
# or if you want to see all potentially relevant configurations.
|
|
79
|
+
#
|
|
80
|
+
# @param context [Hash] A hash representing the current context.
|
|
81
|
+
# @return [Array<ActiveRecord::Base>] An array of all matching configuration instances, ordered by priority.
|
|
82
|
+
def find_all_applicable_configs(context:)
|
|
83
|
+
unless self.respond_to?(:active) && self.respond_to?(:order_by_priority)
|
|
84
|
+
raise(NoMethodError, "#{self.name} must include ContextualConfig::Concern::Configurable to use Lookupable.")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
log_lookup_attempt('all_configs', context, 'all_configs_lookup')
|
|
88
|
+
|
|
89
|
+
# Fetch all active configurations of this type, ordered by priority.
|
|
90
|
+
all_candidates = self.active.order_by_priority
|
|
91
|
+
|
|
92
|
+
# Delegate to the ContextualMatcher service.
|
|
93
|
+
ContextualConfig::Services::ContextualMatcher.find_all_matches(
|
|
94
|
+
candidates: all_candidates,
|
|
95
|
+
context: context
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Generate cache key for configuration lookup
|
|
102
|
+
def generate_cache_key(key, context)
|
|
103
|
+
context_hash = context.is_a?(Hash) ? context.sort.to_h : context
|
|
104
|
+
"contextual_config:#{self.name}:#{key}:#{Digest::MD5.hexdigest(context_hash.to_s)}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Log configuration lookup attempts
|
|
108
|
+
def log_lookup_attempt(key, context, operation)
|
|
109
|
+
return unless ContextualConfig.configuration.enable_logging?
|
|
110
|
+
|
|
111
|
+
logger = ContextualConfig.configuration.effective_logger
|
|
112
|
+
logger.info(
|
|
113
|
+
"ContextualConfig::Lookupable: #{operation} for #{self.name} - Key: #{key}, Context: #{context}"
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Log cache errors
|
|
118
|
+
def log_cache_error(operation, error)
|
|
119
|
+
return unless ContextualConfig.configuration.enable_logging?
|
|
120
|
+
|
|
121
|
+
logger = ContextualConfig.configuration.effective_logger
|
|
122
|
+
logger.warn(
|
|
123
|
+
"ContextualConfig::Lookupable: Cache #{operation} error - #{error.class}: #{error.message}"
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
require 'active_support/concern'
|
|
2
|
+
require 'json-schema'
|
|
3
|
+
|
|
4
|
+
module ContextualConfig
|
|
5
|
+
module Concern
|
|
6
|
+
module SchemaDrivenValidation
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
# --- Validation Hooks ---
|
|
11
|
+
# These methods will be called during the ActiveRecord validation lifecycle (e.g., on save, valid?).
|
|
12
|
+
validate :validate_against_config_data_schema, if: -> { config_data.present? }
|
|
13
|
+
validate :validate_against_scoping_rules_schema, if: -> { scoping_rules.present? }
|
|
14
|
+
# We only run validation if the respective JSONB field has data.
|
|
15
|
+
# If it's blank/nil, we assume it's intentionally so, and schema validation isn't applicable,
|
|
16
|
+
# unless the schema itself marks the root object as required with specific properties.
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# --- Instance Methods for Validation ---
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Validates the `config_data` attribute against its resolved JSON schema.
|
|
24
|
+
def validate_against_config_data_schema
|
|
25
|
+
# Check if validation is enabled for this model's module(s)
|
|
26
|
+
return unless validation_enabled_for_model?
|
|
27
|
+
|
|
28
|
+
schema = self.class.resolve_config_data_schema(self) # Pass instance if schema depends on instance state
|
|
29
|
+
|
|
30
|
+
# Try to get schema from module registry if not provided by class
|
|
31
|
+
if schema.nil?
|
|
32
|
+
module_config = find_module_config_for_model
|
|
33
|
+
schema = module_config&.load_schema
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# If no schema is defined for this specific class/instance, we can choose to:
|
|
37
|
+
# 1. Silently skip validation (as done here by returning if schema is nil).
|
|
38
|
+
# 2. Raise an error indicating a missing schema definition.
|
|
39
|
+
# 3. Add a model error.
|
|
40
|
+
# Skipping allows for flexibility where some types might not require strict schema validation.
|
|
41
|
+
return unless schema
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
# Using fully_validate to get an array of all errors.
|
|
45
|
+
errors_found = JSON::Validator.fully_validate(schema, self.config_data, strict: false, insert_defaults: false)
|
|
46
|
+
if errors_found.any?
|
|
47
|
+
errors_found.each do |error_message|
|
|
48
|
+
# Add a somewhat generic error. More specific parsing of error_message could provide better field-level errors.
|
|
49
|
+
errors.add(:config_data, "is invalid - #{error_message}")
|
|
50
|
+
end
|
|
51
|
+
# Log validation failures
|
|
52
|
+
log_schema_error('config_data', "Validation failed: #{errors_found.join('; ')}")
|
|
53
|
+
end
|
|
54
|
+
rescue JSON::Schema::SchemaError => e
|
|
55
|
+
# This rescue is for when the schema itself is invalid, which is a development-time problem.
|
|
56
|
+
errors.add(:base, "Config data JSON schema for #{self.class.name} is invalid: #{e.message}")
|
|
57
|
+
log_schema_error('config_data', e.message)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Validates the `scoping_rules` attribute against its resolved JSON schema.
|
|
62
|
+
def validate_against_scoping_rules_schema
|
|
63
|
+
# Check if validation is enabled for this model's module(s)
|
|
64
|
+
return unless validation_enabled_for_model?
|
|
65
|
+
|
|
66
|
+
schema = self.class.resolve_scoping_rules_schema(self) # Pass instance
|
|
67
|
+
return unless schema # Skip if no schema defined
|
|
68
|
+
|
|
69
|
+
begin
|
|
70
|
+
errors_found = JSON::Validator.fully_validate(schema, self.scoping_rules, strict: false, insert_defaults: false)
|
|
71
|
+
if errors_found.any?
|
|
72
|
+
errors_found.each do |error_message|
|
|
73
|
+
errors.add(:scoping_rules, "is invalid - #{error_message}")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
rescue JSON::Schema::SchemaError => e
|
|
77
|
+
errors.add(:base, "Scoping rules JSON schema for #{self.class.name} is invalid: #{e.message}")
|
|
78
|
+
log_schema_error('scoping_rules', e.message)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check if validation is enabled for this model in any registered module
|
|
83
|
+
def validation_enabled_for_model?
|
|
84
|
+
# Check module-specific validation settings first
|
|
85
|
+
module_config = find_module_config_for_model
|
|
86
|
+
return module_config.validation_enabled? if module_config
|
|
87
|
+
|
|
88
|
+
# Default to enabled if no module configuration found
|
|
89
|
+
true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Find module configuration for this model
|
|
93
|
+
def find_module_config_for_model
|
|
94
|
+
ContextualConfig.registry.configs_for_model(self.class).first
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Log schema validation errors using configuration logger
|
|
98
|
+
def log_schema_error(field_name, error_message)
|
|
99
|
+
return unless ContextualConfig.configuration.enable_logging?
|
|
100
|
+
|
|
101
|
+
logger = ContextualConfig.configuration.effective_logger
|
|
102
|
+
logger.error("JSON Schema Error for #{self.class.name} (#{field_name}): #{error_message}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# --- Class Methods for Schema Resolution ---
|
|
106
|
+
module ClassMethods
|
|
107
|
+
# These methods are expected to be implemented by any class that includes
|
|
108
|
+
# this concern (e.g., a team's base configuration model or an STI subclass).
|
|
109
|
+
# They are responsible for loading and returning the appropriate JSON schema
|
|
110
|
+
# for `config_data` and `scoping_rules` for instances of that class.
|
|
111
|
+
#
|
|
112
|
+
# The `instance` parameter is optional but can be useful if the schema
|
|
113
|
+
# itself needs to vary based on some attribute of the specific instance
|
|
114
|
+
# being validated (though this is a more advanced use case).
|
|
115
|
+
|
|
116
|
+
# @param instance [ActiveRecord::Base, nil] The instance being validated (optional).
|
|
117
|
+
# @return [Hash, nil] The JSON schema for config_data, or nil if no schema.
|
|
118
|
+
def resolve_config_data_schema(_instance = nil)
|
|
119
|
+
# Try to get schema from module registry first
|
|
120
|
+
module_config = ContextualConfig.registry.configs_for_model(self).first
|
|
121
|
+
schema = module_config&.load_schema if module_config
|
|
122
|
+
return schema if schema
|
|
123
|
+
|
|
124
|
+
# Default implementation: logs a warning and returns nil (no validation).
|
|
125
|
+
# Consuming classes (or their STI parents/children) MUST override this
|
|
126
|
+
# to provide their specific schemas.
|
|
127
|
+
# Example override in consuming class:
|
|
128
|
+
# @_config_data_schema ||= JSON.parse(File.read(Rails.root.join('config/schemas/my_model/config_data.json')))
|
|
129
|
+
log_missing_schema_warning('config_data')
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @param instance [ActiveRecord::Base, nil] The instance being validated (optional).
|
|
134
|
+
# @return [Hash, nil] The JSON schema for scoping_rules, or nil if no schema.
|
|
135
|
+
def resolve_scoping_rules_schema(_instance = nil)
|
|
136
|
+
log_missing_schema_warning('scoping_rules')
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# Log missing schema warnings using configuration logger
|
|
143
|
+
def log_missing_schema_warning(field_name)
|
|
144
|
+
return unless ContextualConfig.configuration.enable_logging?
|
|
145
|
+
|
|
146
|
+
logger = ContextualConfig.configuration.effective_logger
|
|
147
|
+
logger.warn("WARN: JSON schema for :#{field_name} not defined for #{name}. Skipping validation.")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module ContextualConfig
|
|
6
|
+
# Configuration class for gem-wide settings
|
|
7
|
+
class Configuration
|
|
8
|
+
attr_accessor :cache_enabled, :cache_ttl, :cache_store, :default_priority,
|
|
9
|
+
:enable_logging, :logger, :timing_evaluation_enabled
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@cache_enabled = false
|
|
13
|
+
@cache_ttl = 300 # 5 minutes in seconds
|
|
14
|
+
@cache_store = nil
|
|
15
|
+
@default_priority = 100
|
|
16
|
+
@enable_logging = false
|
|
17
|
+
@logger = nil
|
|
18
|
+
@timing_evaluation_enabled = true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Cache configuration
|
|
22
|
+
def cache_enabled?
|
|
23
|
+
@cache_enabled && cache_store_available?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def cache_store_available?
|
|
27
|
+
@cache_store.respond_to?(:read) && @cache_store.respond_to?(:write)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Logging configuration
|
|
31
|
+
def enable_logging?
|
|
32
|
+
@enable_logging && logger_available?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def logger_available?
|
|
36
|
+
@logger.respond_to?(:info) && @logger.respond_to?(:error)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get effective logger
|
|
40
|
+
def effective_logger
|
|
41
|
+
return @logger if logger_available?
|
|
42
|
+
return Rails.logger if defined?(Rails) && Rails.logger
|
|
43
|
+
|
|
44
|
+
Logger.new($stdout)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Timing evaluation configuration
|
|
48
|
+
def timing_evaluation_enabled?
|
|
49
|
+
@timing_evaluation_enabled
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Validate configuration
|
|
53
|
+
def validate!
|
|
54
|
+
if @cache_enabled && !cache_store_available?
|
|
55
|
+
raise(ConfigurationError, 'Cache is enabled but cache_store is not properly configured')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if cache_ttl <= 0
|
|
59
|
+
raise(ConfigurationError, 'cache_ttl must be a positive number')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if default_priority.negative?
|
|
63
|
+
raise(ConfigurationError, 'default_priority must be non-negative')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Reset to defaults
|
|
70
|
+
def reset!
|
|
71
|
+
initialize
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Configuration-specific error
|
|
76
|
+
class ConfigurationError < Error; end
|
|
77
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module ContextualConfig
|
|
6
|
+
# Registry for managing different modules that use ContextualConfig
|
|
7
|
+
class ModuleRegistry
|
|
8
|
+
def initialize
|
|
9
|
+
@modules = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Register a module with its configuration
|
|
13
|
+
def register(module_name, &block)
|
|
14
|
+
module_sym = module_name.to_sym
|
|
15
|
+
config = @modules[module_sym] || ModuleConfig.new(module_name)
|
|
16
|
+
yield(config) if block_given?
|
|
17
|
+
@modules[module_sym] = config
|
|
18
|
+
config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Get a registered module configuration
|
|
22
|
+
def get(module_name)
|
|
23
|
+
@modules[module_name.to_sym]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Get all registered modules
|
|
27
|
+
def all
|
|
28
|
+
@modules.dup
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Check if a module is registered
|
|
32
|
+
def registered?(module_name)
|
|
33
|
+
@modules.key?(module_name.to_sym)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Unregister a module
|
|
37
|
+
def unregister(module_name)
|
|
38
|
+
@modules.delete(module_name.to_sym)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Clear all registrations
|
|
42
|
+
def clear!
|
|
43
|
+
@modules.clear
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Get all module names
|
|
47
|
+
def module_names
|
|
48
|
+
@modules.keys
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get configurations for a specific model class
|
|
52
|
+
def configs_for_model(model_class)
|
|
53
|
+
@modules.values.select { |config| config.model_class == model_class }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get configurations with a specific key prefix
|
|
57
|
+
def configs_with_key_prefix(prefix)
|
|
58
|
+
@modules.values.select { |config| config.key_prefix == prefix }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Configuration for individual modules
|
|
63
|
+
class ModuleConfig
|
|
64
|
+
attr_accessor :model_class, :default_priority, :schema_file, :key_prefix,
|
|
65
|
+
:description, :enabled, :cache_enabled, :validation_enabled
|
|
66
|
+
|
|
67
|
+
attr_reader :name
|
|
68
|
+
|
|
69
|
+
def initialize(name)
|
|
70
|
+
@name = name.to_sym
|
|
71
|
+
@model_class = nil
|
|
72
|
+
@default_priority = ContextualConfig.configuration.default_priority
|
|
73
|
+
@schema_file = nil
|
|
74
|
+
@key_prefix = name.to_s
|
|
75
|
+
@description = nil
|
|
76
|
+
@enabled = true
|
|
77
|
+
@cache_enabled = false
|
|
78
|
+
@validation_enabled = true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check if module is enabled
|
|
82
|
+
def enabled?
|
|
83
|
+
@enabled
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Check if validation is enabled for this module
|
|
87
|
+
def validation_enabled?
|
|
88
|
+
@validation_enabled
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check if caching is enabled for this module
|
|
92
|
+
def cache_enabled?
|
|
93
|
+
@cache_enabled && ContextualConfig.configuration.cache_enabled?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Load schema from file if specified
|
|
97
|
+
def load_schema
|
|
98
|
+
return nil unless schema_file && File.exist?(schema_file)
|
|
99
|
+
|
|
100
|
+
JSON.parse(File.read(schema_file))
|
|
101
|
+
rescue JSON::ParserError => e
|
|
102
|
+
raise(ConfigurationError, "Invalid JSON schema file for module #{name}: #{e.message}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Generate default key for this module
|
|
106
|
+
def default_key(suffix = nil)
|
|
107
|
+
parts = [key_prefix]
|
|
108
|
+
parts << suffix if suffix
|
|
109
|
+
parts.join('.')
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Validate module configuration
|
|
113
|
+
def validate!
|
|
114
|
+
if model_class && !model_class.is_a?(Class)
|
|
115
|
+
raise(ConfigurationError, "model_class must be a Class for module #{name}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if schema_file && !File.exist?(schema_file)
|
|
119
|
+
raise(ConfigurationError, "Schema file does not exist for module #{name}: #{schema_file}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if default_priority.negative?
|
|
123
|
+
raise(ConfigurationError, "default_priority must be non-negative for module #{name}")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Convert to hash for serialization
|
|
130
|
+
def to_h
|
|
131
|
+
{
|
|
132
|
+
name: name,
|
|
133
|
+
model_class: model_class&.name,
|
|
134
|
+
default_priority: default_priority,
|
|
135
|
+
schema_file: schema_file,
|
|
136
|
+
key_prefix: key_prefix,
|
|
137
|
+
description: description,
|
|
138
|
+
enabled: enabled?,
|
|
139
|
+
cache_enabled: cache_enabled?,
|
|
140
|
+
validation_enabled: validation_enabled?
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|