Files
sure/app/models/provider/configurable.rb
soky srm 91a91c3834 Improvements (#379)
* Improvements

- Fix button visibility in reports on light theme
- Unify logic for provider syncs
- Add default option is to skip accounts linking ( no op default )

* Stability fixes and UX improvements

* FIX add unlinking when deleting lunch flow connection as well

* Wrap updates in transaction

* Some more improvements

* FIX proper provider setup check

* Make provider section collapsible

* Fix balance calculation

* Restore focus ring

* Use browser default focus

* Fix lunch flow balance for credit cards
2025-11-25 20:21:29 +01:00

308 lines
8.9 KiB
Ruby

# 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 as individual entries using
# RailsSettings::Base's bracket-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
@configured_check = 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 custom check for whether this provider is configured
# @param block [Proc] A block that returns true if the provider is configured
# Example:
# configured_check { get_value(:client_id).present? && get_value(:secret).present? }
def configured_check(&block)
@configured_check = block
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 provider is properly configured
# Uses custom configured_check if defined, otherwise checks required fields
def configured?
if @configured_check
instance_eval(&@configured_check)
else
required_fields = fields.select(&:required)
if required_fields.any?
required_fields.all? { |f| f.value.present? }
else
# If no required fields, provider is not considered configured
# unless it defines a custom configured_check
false
end
end
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 bracket-style access
# Each field is stored as an individual entry without explicit field declarations
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