discourse/lib/service/contract_base.rb
Loïc Guitaut 89be127ced
DEV: Handle nested attributes in contracts (#36348)
This change adds the ability to validate more complex structures in the
Ruby service contracts.

Contracts were limited to flat structures, which is fine most of the
time, but it can become tedious when managing lots of attributes.

With this new feature, contracts like this one can be defined:
```ruby
attribute :channel_id, :integer

attribute :record, :hash do
  attribute :id, :integer
  attribute :created_at, :datetime
  attribute :enabled, :boolean
end

attribute :user, :hash do
  attribute :username, :string
  attribute :age, :integer

  validates :username, presence: true
end

attribute :items, :array do
  attribute :name, :string

  validates :name, presence: true
end

validates :channel_id, presence: true
```

Two nested types are available: `hash` and `array`.

Each block creates a new contract, meaning coercions, validations and
callbacks are available as usual.
2025-12-12 11:41:48 +01:00

83 lines
2 KiB
Ruby

# frozen_string_literal: true
class Service::ContractBase
include ActiveModel::API
include ActiveModel::Attributes
include ActiveModel::AttributeMethods
include ActiveModel::Validations::Callbacks
delegate :slice, :merge, to: :to_hash
class << self
def attribute(name, cast_type = nil, **options, &block)
return super(name, cast_type, **options) unless block_given?
nested_contract_class =
Class.new(Service::ContractBase) do
define_singleton_method(:name) { "#{name}_contract".classify }
class_eval(&block)
end
super(
name,
Service::NestedContractType.new(
contract_class: nested_contract_class,
nested_type: cast_type || :hash,
),
**options,
)
end
end
def initialize(*args, options: nil, **kwargs)
@__options__ = options
kwargs.deep_symbolize_keys!.slice!(*self.class.attribute_names.map(&:to_sym))
super(*args, **kwargs)
end
def options
@__options__
end
def to_hash
attributes.symbolize_keys.deep_transform_values do
_1.is_a?(Service::ContractBase) ? _1.to_hash : _1
end
end
def raw_attributes
@attributes.values_before_type_cast
end
def valid?(context = nil)
[super, nested_attributes_valid?].all?
end
private
def nested_attributes_valid?
nested_attributes.map(&method(:validate_nested)).all?
end
def nested_attributes
@attributes.each_value.select { _1.type.is_a?(Service::NestedContractType) && _1.value }
end
def validate_nested(attribute)
Array
.wrap(attribute.value)
.map
.with_index do |contract, index|
next true if contract.valid?
import_nested_errors(contract, attribute, index)
false
end
.all?
end
def import_nested_errors(contract, attribute, index)
array_index = "[#{index}]" if attribute.value.is_a?(Array)
contract.errors.each do |error|
errors.import(error, attribute: :"#{attribute.name}#{array_index}.#{error.attribute}")
end
end
end