mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
Add support for dynamic config UI (#256)
* Add support for dynamic config UI
* Add support for section description
* Better dynamic class settings
Added dynamic_fields hash field - Stores all undeclared settings
[] method - Checks declared fields first, then falls back to dynamic hash
[]= method - Updates declared fields normally, stores others in hash
No runtime field declaration - Fields are never dynamically created on the class
* FIX proper lookup for provider keys
- Also validate configurable values properly.
- Change Provider factory to use Rails autoloading (Zeitwerk)
* Fix factory
The derive_adapter_name method relies on string manipulation ("PlaidAccount".sub(/Account$/, "") + "Adapter" → "PlaidAdapter"), but we already have explicit registration in place.
* Make updates atomic, field-aware, and handle blanks explicitly
* Small UX detail
* Add support for PlaidEU in UI also
- This looks like partial support atm
This commit is contained in:
286
app/models/provider/configurable.rb
Normal file
286
app/models/provider/configurable.rb
Normal file
@@ -0,0 +1,286 @@
|
||||
# Module for providers to declare their configuration requirements
|
||||
#
|
||||
# Providers can declare their own configuration fields without needing to modify
|
||||
# the Setting model. Settings are stored dynamically using RailsSettings::Base's
|
||||
# hash-style access (Setting[:key] = value).
|
||||
#
|
||||
# Configuration fields are automatically registered and displayed in the UI at
|
||||
# /settings/providers. The system checks Setting storage first, then ENV variables,
|
||||
# then falls back to defaults.
|
||||
#
|
||||
# Example usage in an adapter:
|
||||
# class Provider::PlaidAdapter < Provider::Base
|
||||
# include Provider::Configurable
|
||||
#
|
||||
# configure do
|
||||
# description <<~DESC
|
||||
# Setup instructions:
|
||||
# 1. Visit [Plaid Dashboard](https://dashboard.plaid.com) to get your API credentials
|
||||
# 2. Configure your Client ID and Secret Key below
|
||||
# DESC
|
||||
#
|
||||
# field :client_id,
|
||||
# label: "Client ID",
|
||||
# required: true,
|
||||
# env_key: "PLAID_CLIENT_ID",
|
||||
# description: "Your Plaid Client ID from the dashboard"
|
||||
#
|
||||
# field :secret,
|
||||
# label: "Secret Key",
|
||||
# required: true,
|
||||
# secret: true,
|
||||
# env_key: "PLAID_SECRET",
|
||||
# description: "Your Plaid Secret key"
|
||||
#
|
||||
# field :environment,
|
||||
# label: "Environment",
|
||||
# required: false,
|
||||
# env_key: "PLAID_ENV",
|
||||
# default: "sandbox",
|
||||
# description: "Plaid environment: sandbox, development, or production"
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# The provider_key is automatically derived from the class name:
|
||||
# Provider::PlaidAdapter -> "plaid"
|
||||
# Provider::SimplefinAdapter -> "simplefin"
|
||||
#
|
||||
# Fields are stored with keys like "plaid_client_id", "plaid_secret", etc.
|
||||
# Access values via: configuration.get_value(:client_id) or field.value
|
||||
module Provider::Configurable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
# Define configuration for this provider
|
||||
def configure(&block)
|
||||
@configuration = Configuration.new(provider_key)
|
||||
@configuration.instance_eval(&block)
|
||||
Provider::ConfigurationRegistry.register(provider_key, @configuration, self)
|
||||
end
|
||||
|
||||
# Get the configuration for this provider
|
||||
def configuration
|
||||
@configuration || Provider::ConfigurationRegistry.get(provider_key)
|
||||
end
|
||||
|
||||
# Get the provider key (derived from class name)
|
||||
# Example: Provider::PlaidAdapter -> "plaid"
|
||||
def provider_key
|
||||
name.demodulize.gsub(/Adapter$/, "").underscore
|
||||
end
|
||||
|
||||
# Get a configuration value
|
||||
def config_value(field_name)
|
||||
configuration&.get_value(field_name)
|
||||
end
|
||||
|
||||
# Check if provider is configured (all required fields present)
|
||||
def configured?
|
||||
configuration&.configured? || false
|
||||
end
|
||||
|
||||
# Reload provider-specific configuration (override in subclasses if needed)
|
||||
# This is called after settings are updated in the UI
|
||||
# Example: reload Rails.application.config values, reinitialize API clients, etc.
|
||||
def reload_configuration
|
||||
# Default implementation does nothing
|
||||
# Override in provider adapters that need to reload configuration
|
||||
end
|
||||
end
|
||||
|
||||
# Instance methods
|
||||
def provider_key
|
||||
self.class.provider_key
|
||||
end
|
||||
|
||||
def configuration
|
||||
self.class.configuration
|
||||
end
|
||||
|
||||
def config_value(field_name)
|
||||
self.class.config_value(field_name)
|
||||
end
|
||||
|
||||
def configured?
|
||||
self.class.configured?
|
||||
end
|
||||
|
||||
# Configuration DSL
|
||||
class Configuration
|
||||
attr_reader :provider_key, :fields, :provider_description
|
||||
|
||||
def initialize(provider_key)
|
||||
@provider_key = provider_key
|
||||
@fields = []
|
||||
@provider_description = nil
|
||||
end
|
||||
|
||||
# Set the provider-level description (markdown supported)
|
||||
# @param text [String] The description text for this provider
|
||||
def description(text)
|
||||
@provider_description = text
|
||||
end
|
||||
|
||||
# Define a configuration field
|
||||
# @param name [Symbol] The field name
|
||||
# @param label [String] Human-readable label
|
||||
# @param required [Boolean] Whether this field is required
|
||||
# @param secret [Boolean] Whether this field contains sensitive data (will be masked in UI)
|
||||
# @param env_key [String] The ENV variable key for this field
|
||||
# @param default [String] Default value if none provided
|
||||
# @param description [String] Optional help text
|
||||
def field(name, label:, required: false, secret: false, env_key: nil, default: nil, description: nil)
|
||||
@fields << ConfigField.new(
|
||||
name: name,
|
||||
label: label,
|
||||
required: required,
|
||||
secret: secret,
|
||||
env_key: env_key,
|
||||
default: default,
|
||||
description: description,
|
||||
provider_key: @provider_key
|
||||
)
|
||||
end
|
||||
|
||||
# Get value for a field (checks Setting, then ENV, then default)
|
||||
def get_value(field_name)
|
||||
field = fields.find { |f| f.name == field_name }
|
||||
return nil unless field
|
||||
|
||||
field.value
|
||||
end
|
||||
|
||||
# Check if all required fields are present
|
||||
def configured?
|
||||
fields.select(&:required).all? { |f| f.value.present? }
|
||||
end
|
||||
|
||||
# Get all field values as a hash
|
||||
def to_h
|
||||
fields.each_with_object({}) do |field, hash|
|
||||
hash[field.name] = field.value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Represents a single configuration field
|
||||
class ConfigField
|
||||
attr_reader :name, :label, :required, :secret, :env_key, :default, :description, :provider_key
|
||||
|
||||
def initialize(name:, label:, required:, secret:, env_key:, default:, description:, provider_key:)
|
||||
@name = name
|
||||
@label = label
|
||||
@required = required
|
||||
@secret = secret
|
||||
@env_key = env_key
|
||||
@default = default
|
||||
@description = description
|
||||
@provider_key = provider_key
|
||||
end
|
||||
|
||||
# Get the setting key for this field
|
||||
# Example: plaid_client_id
|
||||
def setting_key
|
||||
"#{provider_key}_#{name}".to_sym
|
||||
end
|
||||
|
||||
# Get the value for this field (Setting -> ENV -> default)
|
||||
def value
|
||||
# First try Setting using dynamic hash-style access
|
||||
# This works even without explicit field declarations in Setting model
|
||||
setting_value = Setting[setting_key]
|
||||
return normalize_value(setting_value) if setting_value.present?
|
||||
|
||||
# Then try ENV if env_key is specified
|
||||
if env_key.present?
|
||||
env_value = ENV[env_key]
|
||||
return normalize_value(env_value) if env_value.present?
|
||||
end
|
||||
|
||||
# Finally return default
|
||||
normalize_value(default)
|
||||
end
|
||||
|
||||
# Check if this field has a value
|
||||
def present?
|
||||
value.present?
|
||||
end
|
||||
|
||||
# Validate the current value
|
||||
# Returns true if valid, false otherwise
|
||||
def valid?
|
||||
validate.empty?
|
||||
end
|
||||
|
||||
# Get validation errors for the current value
|
||||
# Returns an array of error messages
|
||||
def validate
|
||||
errors = []
|
||||
current_value = value
|
||||
|
||||
# Required validation
|
||||
if required && current_value.blank?
|
||||
errors << "#{label} is required"
|
||||
end
|
||||
|
||||
# Additional validations can be added here in the future:
|
||||
# - Format validation (regex)
|
||||
# - Length validation
|
||||
# - Enum validation
|
||||
# - Custom validation blocks
|
||||
|
||||
errors
|
||||
end
|
||||
|
||||
# Validate and raise an error if invalid
|
||||
def validate!
|
||||
errors = validate
|
||||
raise ArgumentError, "Invalid configuration for #{setting_key}: #{errors.join(", ")}" if errors.any?
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
# Normalize value by stripping whitespace and converting empty strings to nil
|
||||
def normalize_value(val)
|
||||
return nil if val.nil?
|
||||
normalized = val.to_s.strip
|
||||
normalized.empty? ? nil : normalized
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Registry to store all provider configurations
|
||||
module Provider::ConfigurationRegistry
|
||||
class << self
|
||||
def register(provider_key, configuration, adapter_class = nil)
|
||||
registry[provider_key] = configuration
|
||||
adapter_registry[provider_key] = adapter_class if adapter_class
|
||||
end
|
||||
|
||||
def get(provider_key)
|
||||
registry[provider_key]
|
||||
end
|
||||
|
||||
def all
|
||||
registry.values
|
||||
end
|
||||
|
||||
def providers
|
||||
registry.keys
|
||||
end
|
||||
|
||||
# Get the adapter class for a provider key
|
||||
def get_adapter_class(provider_key)
|
||||
adapter_registry[provider_key]
|
||||
end
|
||||
|
||||
private
|
||||
def registry
|
||||
@registry ||= {}
|
||||
end
|
||||
|
||||
def adapter_registry
|
||||
@adapter_registry ||= {}
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user