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:
soky srm
2025-10-29 13:11:04 +01:00
committed by GitHub
parent 9fefe57de5
commit 96713ee8b4
13 changed files with 763 additions and 43 deletions

View 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

View File

@@ -1,4 +1,6 @@
class Provider::Factory
class AdapterNotFoundError < StandardError; end
class << self
# Register a provider adapter
# @param provider_type [String] The provider account class name (e.g., "PlaidAccount")
@@ -15,15 +17,9 @@ class Provider::Factory
return nil if provider_account.nil?
provider_type = provider_account.class.name
adapter_class = registry[provider_type]
adapter_class = find_adapter_class(provider_type)
# If not registered, try to load the adapter
if adapter_class.nil?
ensure_adapters_loaded
adapter_class = registry[provider_type]
end
raise ArgumentError, "Unknown provider type: #{provider_type}. Did you forget to register it?" unless adapter_class
raise AdapterNotFoundError, "No adapter registered for provider type: #{provider_type}" unless adapter_class
adapter_class.new(provider_account, account: account)
end
@@ -41,7 +37,35 @@ class Provider::Factory
# @return [Array<String>] List of registered provider type names
def registered_provider_types
ensure_adapters_loaded
registry.keys
registry.keys.sort
end
# Ensures all provider adapters are loaded and registered
# Uses Rails autoloading to discover adapters dynamically
def ensure_adapters_loaded
# Eager load all adapter files to trigger their registration
adapter_files.each do |adapter_name|
adapter_class_name = "Provider::#{adapter_name}"
# Use Rails autoloading (constantize) instead of require
begin
adapter_class_name.constantize
rescue NameError => e
Rails.logger.warn("Failed to load adapter: #{adapter_class_name} - #{e.message}")
end
end
end
# Check if a provider type has a registered adapter
# @param provider_type [String] The provider account class name
# @return [Boolean]
def registered?(provider_type)
find_adapter_class(provider_type).present?
end
# Clear all registered adapters (useful for testing)
def clear_registry!
@registry = {}
end
private
@@ -50,17 +74,28 @@ class Provider::Factory
@registry ||= {}
end
# Ensures all provider adapters are loaded
# This is needed for Rails autoloading in development/test environments
def ensure_adapters_loaded
return if @adapters_loaded
# Find adapter class, attempting to load all adapters if not registered
def find_adapter_class(provider_type)
# Return if already registered
return registry[provider_type] if registry[provider_type]
# Require all adapter files to trigger registration
Dir[Rails.root.join("app/models/provider/*_adapter.rb")].each do |file|
require_dependency file
# Load all adapters to ensure they're registered
# This triggers their self-registration calls
ensure_adapters_loaded
# Check registry again after loading
registry[provider_type]
end
# Discover all adapter files in the provider directory
# Returns adapter class names (e.g., ["PlaidAdapter", "SimplefinAdapter"])
def adapter_files
return [] unless defined?(Rails)
pattern = Rails.root.join("app/models/provider/*_adapter.rb")
Dir[pattern].map do |file|
File.basename(file, ".rb").camelize
end
@adapters_loaded = true
end
end
end

View File

@@ -1,14 +1,72 @@
# PlaidAdapter serves dual purposes:
#
# 1. Configuration Manager (class-level):
# - Manages Rails.application.config.plaid (US region)
# - Exposes 3 configurable fields in "Plaid" section of settings UI
# - PlaidEuAdapter separately manages EU region in "Plaid Eu" section
#
# 2. Instance Adapter (instance-level):
# - Wraps ALL PlaidAccount instances regardless of region (US or EU)
# - The PlaidAccount's plaid_item.plaid_region determines which config to use
# - Delegates to Provider::Registry.plaid_provider_for_region(region)
class Provider::PlaidAdapter < Provider::Base
include Provider::Syncable
include Provider::InstitutionMetadata
include Provider::Configurable
# Register this adapter with the factory
# Register this adapter with the factory for ALL PlaidAccount instances
Provider::Factory.register("PlaidAccount", self)
# Configuration for Plaid US
configure do
description <<~DESC
Setup instructions:
1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials
2. Your Client ID and Secret Key are required to enable Plaid bank sync for US/CA banks
3. For production use, set environment to 'production', for testing use 'sandbox'
DESC
field :client_id,
label: "Client ID",
required: false,
env_key: "PLAID_CLIENT_ID",
description: "Your Plaid Client ID from the Plaid Dashboard"
field :secret,
label: "Secret Key",
required: false,
secret: true,
env_key: "PLAID_SECRET",
description: "Your Plaid Secret from the Plaid Dashboard"
field :environment,
label: "Environment",
required: false,
env_key: "PLAID_ENV",
default: "sandbox",
description: "Plaid environment: sandbox, development, or production"
end
def provider_name
"plaid"
end
# Reload Plaid US configuration when settings are updated
def self.reload_configuration
client_id = config_value(:client_id).presence || ENV["PLAID_CLIENT_ID"]
secret = config_value(:secret).presence || ENV["PLAID_SECRET"]
environment = config_value(:environment).presence || ENV["PLAID_ENV"] || "sandbox"
if client_id.present? && secret.present?
Rails.application.config.plaid = Plaid::Configuration.new
Rails.application.config.plaid.server_index = Plaid::Configuration::Environment[environment]
Rails.application.config.plaid.api_key["PLAID-CLIENT-ID"] = client_id
Rails.application.config.plaid.api_key["PLAID-SECRET"] = secret
else
Rails.application.config.plaid = nil
end
end
def sync_path
Rails.application.routes.url_helpers.sync_plaid_item_path(item)
end

View File

@@ -0,0 +1,61 @@
# PlaidEuAdapter is a configuration-only manager for Plaid EU credentials.
#
# It does NOT register as a provider type because:
# - There's no separate "PlaidEuAccount" model
# - All PlaidAccounts (regardless of region) use PlaidAdapter as their instance adapter
#
# This class only manages Rails.application.config.plaid_eu, which
# Provider::Registry.plaid_provider_for_region(:eu) uses to create Provider::Plaid instances.
#
# This separation into a distinct adapter class provides:
# - Clear UI separation: "Plaid" vs "Plaid Eu" sections in settings
# - Better UX: Users only configure the region they need
class Provider::PlaidEuAdapter
include Provider::Configurable
# Configuration for Plaid EU
configure do
description <<~DESC
Setup instructions:
1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials
2. Your Client ID and Secret Key are required to enable Plaid bank sync for European banks
3. For production use, set environment to 'production', for testing use 'sandbox'
DESC
field :client_id,
label: "Client ID",
required: false,
env_key: "PLAID_EU_CLIENT_ID",
description: "Your Plaid Client ID from the Plaid Dashboard for EU region"
field :secret,
label: "Secret Key",
required: false,
secret: true,
env_key: "PLAID_EU_SECRET",
description: "Your Plaid Secret from the Plaid Dashboard for EU region"
field :environment,
label: "Environment",
required: false,
env_key: "PLAID_EU_ENV",
default: "sandbox",
description: "Plaid environment: sandbox, development, or production"
end
# Reload Plaid EU configuration when settings are updated
def self.reload_configuration
client_id = config_value(:client_id).presence || ENV["PLAID_EU_CLIENT_ID"]
secret = config_value(:secret).presence || ENV["PLAID_EU_SECRET"]
environment = config_value(:environment).presence || ENV["PLAID_EU_ENV"] || "sandbox"
if client_id.present? && secret.present?
Rails.application.config.plaid_eu = Plaid::Configuration.new
Rails.application.config.plaid_eu.server_index = Plaid::Configuration::Environment[environment]
Rails.application.config.plaid_eu.api_key["PLAID-CLIENT-ID"] = client_id
Rails.application.config.plaid_eu.api_key["PLAID-SECRET"] = secret
else
Rails.application.config.plaid_eu = nil
end
end
end

View File

@@ -1,10 +1,27 @@
class Provider::SimplefinAdapter < Provider::Base
include Provider::Syncable
include Provider::InstitutionMetadata
include Provider::Configurable
# Register this adapter with the factory
Provider::Factory.register("SimplefinAccount", self)
# Configuration for SimpleFIN
configure do
description <<~DESC
Setup instructions:
1. Visit [SimpleFIN Bridge](https://bridge.simplefin.org/simplefin/create) to get a setup token
2. This token is optional and only needed if you want to provide a default setup token for users
DESC
field :setup_token,
label: "Setup Token",
required: false,
secret: true,
env_key: "SIMPLEFIN_SETUP_TOKEN",
description: "Optional: SimpleFIN setup token from your SimpleFIN Bridge account (one-time use)"
end
def provider_name
"simplefin"
end

View File

@@ -4,12 +4,18 @@ class Setting < RailsSettings::Base
cache_prefix { "v1" }
# Third-party API keys
field :twelve_data_api_key, type: :string, default: ENV["TWELVE_DATA_API_KEY"]
field :openai_access_token, type: :string, default: ENV["OPENAI_ACCESS_TOKEN"]
field :openai_uri_base, type: :string, default: ENV["OPENAI_URI_BASE"]
field :openai_model, type: :string, default: ENV["OPENAI_MODEL"]
field :brand_fetch_client_id, type: :string, default: ENV["BRAND_FETCH_CLIENT_ID"]
# Single hash field for all dynamic provider credentials and other dynamic settings
# This allows unlimited dynamic fields without declaring them upfront
field :dynamic_fields, type: :hash, default: {}
# Onboarding and app settings
ONBOARDING_STATES = %w[open closed invite_only].freeze
DEFAULT_ONBOARDING_STATE = begin
env_value = ENV["ONBOARDING_STATE"].to_s.presence || "open"
@@ -42,6 +48,56 @@ class Setting < RailsSettings::Base
self.require_invite_for_signup = state == "invite_only"
self.raw_onboarding_state = state
end
# Support dynamic field access via bracket notation
# First checks if it's a declared field, then falls back to dynamic_fields hash
def [](key)
key_str = key.to_s
# Check if it's a declared field first
if respond_to?(key_str)
public_send(key_str)
else
# Fall back to dynamic_fields hash
dynamic_fields[key_str]
end
end
def []=(key, value)
key_str = key.to_s
# If it's a declared field, use the setter
if respond_to?("#{key_str}=")
public_send("#{key_str}=", value)
else
# Otherwise, store in dynamic_fields hash
current_dynamic = dynamic_fields.dup
current_dynamic[key_str] = value
self.dynamic_fields = current_dynamic
end
end
# Check if a dynamic field exists (useful to distinguish nil value vs missing key)
def key?(key)
key_str = key.to_s
respond_to?(key_str) || dynamic_fields.key?(key_str)
end
# Delete a dynamic field
def delete(key)
key_str = key.to_s
return nil if respond_to?(key_str) # Can't delete declared fields
current_dynamic = dynamic_fields.dup
value = current_dynamic.delete(key_str)
self.dynamic_fields = current_dynamic
value
end
# List all dynamic field keys (excludes declared fields)
def dynamic_keys
dynamic_fields.keys
end
end
# Validates OpenAI configuration requires model when custom URI base is set