How can validations be reused between dry-validation and ActiveModel?

I’m attempting to move away from reform to dry-validation. One reform feature we were making use of was the ability to reuse our existing ActiveModel validations:

class Jobs::UpdateForm < Reform::Form
  extend ActiveModel::ModelValidations

  # various properties

  copy_validations_from Project
end

This made it convenient to move validation from our models towards the edges of our application, but we also kept the validations on the models as a “last line of defense” and because not all paths went through the new validations.

I don’t see an obvious way to do the same with dry-validations.

My best idea so far is to define a base contract for a given model and have a bunch of rules as macros. Then the “pure model” contract would use all of those macros and I’d somehow shuttle the errors back to ActiveModel. For forms / JSON endpoints, I’d define the properties that are allowed on that endpoint and then use the appropriate macros.

I don’t see a way around duplicating the dry-logic bits (e.g. .value(:integer, gt?: -365, lt?: 365)).

Are there any great patterns that exist that I could crib from to get started?

I…am not sure what you want to do. Could you rephrase or show some code?

I have an existing ActiveRecord/ActiveModel model with validations:

require 'active_model'

class MyValidations < ActiveModel::Validator
  def validate(record)
    if record.name.length > 24
      record.errors.add :name, "This name is too long, try using the description instead"
    end
  end
end

class MyModel
  include ActiveModel::Model
  include ActiveModel::Validations

  attr_accessor :id, :name, :description

  validates_with MyValidations
end

m = MyModel.new(name: 'a' * 50)
puts m.validate
# => false
puts m.errors.messages
# => {:name=>["This name is too long, try using the description instead"]}

I wish to use dry-validations at the edges of my program, so I create a contract:

require 'dry-validation'

class MyContract < Dry::Validation::Contract
  params do
    optional(:name).value(:string)
  end

  rule(:name) do
    if value.length > 24
      key.failure "This name is too long, try using the description instead"
    end
  end
end

validation = MyContract.new.call(name: 'a' * 50)
puts validation.success?
# => false
puts validation.errors.to_h
# => {:name=>["This name is too long, try using the description instead"]}

In doing so, I have duplicated the logic of my validations out of MyValidations into MyContract. I do not want this logic to be duplicated.

What I have done so far is to create an adapter:

class ContractValidator
  def initialize(options)
    @contract_class = options.fetch(:contract) { raise "The `contract:` option is required" }
  end

  def validate(record)
    contract = @contract_class.new

    validation = contract.call(record.attributes)
    validation.errors.to_h.each { |k, v| v.each { |v| record.errors.add(k, v) } }
  end
end

I can then use this in my model:

class MyModel
  include ActiveModel::Model
  include ActiveModel::Validations

  attr_accessor :id, :name, :description

  validates_with ContractValidator, contract: ::MyContract

  def attributes
    { id: id, name: name, description: description }
  end
end

In the real app, I create a base contract with all the rules as macros, then subcontracts for “model creation via HTTP”, “model editing via HTTP”, “model specific change via HTTP”, etc. I also create a subcontract for “model saving” which is what is actually used in the validates_with.

I don’t see a way to reuse the dry-logic predicates.