mirror of
https://github.com/we-promise/sure.git
synced 2026-04-17 19:14:11 +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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
61
app/models/provider/plaid_eu_adapter.rb
Normal file
61
app/models/provider/plaid_eu_adapter.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user