Skip to content

Instantly share code, notes, and snippets.

@ianwhite
Last active February 18, 2024 01:48
Show Gist options
  • Save ianwhite/a32fcd439020ea07ed1fe3243152274f to your computer and use it in GitHub Desktop.
Save ianwhite/a32fcd439020ea07ed1fe3243152274f to your computer and use it in GitHub Desktop.
Spike for composable contracts in dry.rb (examples below the code)
module ComposableContract
# this is the interface that we care about, we provide the rest of
# result's interface where required
ResultType = Types.Interface(:to_h, :errors)
# this is our representation of a path, we use Schema::Path to
# convert where necessary
PathType = Types::Array.of(Types::Symbol)
CheckType = Types.Interface(:call)
ChecksType = Types::Array(CheckType)
ChecksDictType = Types::Hash.map(Types::Symbol, CheckType)
def self.compose(&block)
builder(&block).to_contract
end
def self.builder(&block)
DSL.new(&block)
end
def self.included(klass)
klass.extend ClassInterface
end
# if included in a contract, start the composition with the contract
# result, otherwise start from scratch
def call(input)
result = super if method(:call).super_method
result = composition.call(input, result) if composition
result
end
private
def composition
self.class.instance_variable_get(:@composition)
end
module ClassInterface
def compose(&block)
@composition = ComposableContract.compose(&block)
end
end
# a step contains a contract, optional checks, and optional path
class Step < Dry::Struct
attribute :contract, Types.Interface(:call) | Types.Instance(Class)
attribute :checks, ChecksType
attribute :path, PathType.optional
# collaborates to add results of this contract to the result set
def call(input, result_set)
return result_set unless checks_pass?(result_set)
the_input = path ? input.dig(*path) : input
the_contract = contract.respond_to?(:call) ? contract : contract.new
the_result = the_contract.call(the_input)
result_set.add_result(the_result, path)
end
private
def checks_pass?(result_set)
check_evaluator = CheckEvaluator.new(result_set.values)
checks.all? { |c| check_evaluator.instance_exec(&c) }
end
CheckEvaluator = Struct.new(:values)
end
# a composition is an ordered list of steps, quacks like a Contract
# but allows for an initial result to be passed to #call
class Composition < Dry::Struct
attribute :steps, Types.Array(Step)
# note optional extra argument result
def call(input, result = nil)
starting_result_set = ResultSet.new(result ? [result] : [])
steps.each_with_object(starting_result_set) do |step, result_set|
step.call(input, result_set)
end.freeze
end
end
# DSL exposes #steps (an ordered list of steps), suitable for a Composition
class DSL
# can be passed a 1-arity or zero arity block
def initialize(&block)
@steps = [] # 3-tuples of [contract, path, checks]
@checks_dict = {}
block.arity == 1 ? block.call(self) : instance_exec(&block) if block
end
# before returning steps, resolve any named checks
def steps
@steps.map do |(contract, path, checks)|
checks = checks.map { |c| c.is_a?(Symbol) ? @checks_dict.fetch(c) : c }
Step.new(contract: contract, checks: checks, path: path)
end
end
def to_contract
Composition.new(steps: steps)
end
def contract(contract, check: nil, path: nil)
path = Dry::Schema::Path[path].to_a if path
path = [*@current_path, *path] if path || @current_path
checks = [*@current_checks, *check]
@steps << [contract, path, checks]
end
def check(*checks)
prev_checks = @current_checks
@current_checks = [*@current_checks, *checks]
yield
ensure
@current_checks = prev_checks
end
def path(path)
prev_path = @current_path
@current_path = [*@current_path, *Dry::Schema::Path[path].to_a]
yield
ensure
@current_path = prev_path
end
def register_check(name, &block)
@checks_dict = ChecksDictType[@checks_dict.merge(name => block)]
end
end
# quacks like a Dry::Validation::Result, except #add_error.
# Can be composed of results (#add_result) optionally mounted at paths.
# its values and errors are merged from all of its results
class ResultSet
extend Forwardable
def initialize(results = [])
@success = nil
@message_set = nil
@results = []
results.each { |r| add_result(r) }
end
def add_result(result, path = nil)
@values = nil
@success = nil
result = ResultType[result]
result = ResultAtPath[result: result, path: path] if path
@results << result
self
end
def freeze
@results.map(&:freeze)
success?
message_set.freeze
values.freeze
super
end
def values
@values ||= Dry::Validation::Values.new(merge_all_values)
end
def_delegators :values, :[], :key?, :to_h
def errors(new_options = {})
new_options.empty? ? message_set : collate_all_messages(new_options)
end
def error?(key)
message_set.any? do |msg|
Dry::Schema::Path[msg.path].include?(Dry::Schema::Path[key])
end
end
def base_error?(key)
message_set.any? do |msg|
key_path = Dry::Schema::Path[key]
err_path = Dry::Schema::Path[msg.path]
return false unless key_path.same_root?(err_path)
key_path == err_path
end
end
def success?
return @success unless @success.nil?
@success = message_set.empty?
end
def failure?
!success?
end
private
def message_set
# memoize result errors only if underlying results are all frozen
@message_set || collate_all_messages.tap do |message_set|
@message_set = message_set unless @results.all?(&:frozen?)
end
end
def collate_all_messages(options = {})
empty_message_set = Dry::Validation::MessageSet.new([], options)
@results.each_with_object(empty_message_set) do |result, errors|
result.errors(options).each { |m| errors.add(m) }
end
end
def merge_all_values
@results.reduce({}) { |data, result| data.merge(result.to_h) }
end
end
# api private
class ResultAtPath < Dry::Struct
attribute :result, ResultType
attribute :path, PathType
def to_h
@to_h ||= hash_at_path
end
def errors(new_options = {})
new_options.empty? ? message_set : errors_at_path(new_options)
end
def freeze
result.freeze
message_set.freeze
to_h.freeze
super
end
def add_error(*args)
result.add_error(*args)
end
private
def message_set
# memoize result errors only if underlying result is frozen
@message_set || errors_at_path.tap do |message_set|
@message_set = message_set unless result.frozen?
end
end
def hash_at_path
data = path.reverse.reduce({}) { |m, key| { key => m } }
data.dig(*path).merge!(result.to_h)
data
end
def errors_at_path(options = {})
empty_message_set = Dry::Validation::MessageSet.new([], options)
result.errors(options).each_with_object(empty_message_set) do |m, errors|
errors.add Dry::Validation::Message[m.text, path + m.path, m.meta]
end
end
end
end
## standalone block
composed = ComposableContract.compose do
contract CustomerContract
path :address do
contract Address
end
check -> { values[:company] } do
contract Company, path: 'details.company'
end
end
composed.call({address: {...}, email: ...) # => returns a result
## inside a contract, full example from a project
# the class OrderContract at the very bottom uses the compose functionality
class ApplicationContract < Dry::Validation::Contract
include ComposableContract
end
# this contract uses the compose functionality, and refers to other contracts below
# expects hash of order data, customer data,
# nested :address, and possibly a nested :delivery_address
class OrderContract < ApplicationContract
params do
required(:delivery_required).value(:bool)
required(:delivery_address_same).value(:bool)
required(:accept_terms).value(:bool)
end
rule(:accept_terms).validate(:acceptance)
compose do
contract CustomerContract
path :address do
contract AddressContract
end
path :delivery_address do
contract AddressContract, check: :require_delivery_address?
end
register_check :require_delivery_address? do
values[:delivery_required] && !values[:delivery_address_same]
end
end
end
# customer details
class CustomerContract < ApplicationContract
params do
required(:full_name).filled(:string)
required(:email).value(Types::Email)
required(:phone).value(Types::Phone)
optional(:company).value(:string)
end
rule(:email).validate(:email_reachable)
end
# address details
class AddressContract < ApplicationContract
params do
required(:street1).value(Types::AddressLine)
required(:city).value(Types::AddressLine)
required(:postcode).value(Types::PossibleUkPostcode) # see rule below
optional(:street2).value(Types::AddressLine)
optional(:county).value(Types::AddressLine)
optional(:skip_postcode).value(:bool)
optional(:addressee).filled(:string)
end
rule(:postcode) do
unless values[:skip_postcode]
key.failure(:format?) unless Types::UkPostcode.valid?(value)
end
end
end
@jwhitcraft
Copy link

I know this is about 5 years old now, but was wondering if this works for doing array of contracts.

environments: # The deployment tier of this realm.
  development:
    config: # a config for each job needs to be here
      - name: job
         other: 1
      - name: webapp
         other: 2

Eg i need to have the config element be a contract that validates that each items in the config: array is a contract.

Here is some sudo code as to what i'm trying to do here:

class ApplicationContract < Dry::Validation::Contract
  include ComposableContract
end

class TestApp < ApplicationContract
  params do
    required(:enabledEnvironments).value(:array, min_size?: 1).each(:string)
  end

  compose do
    register_check :require_development? do
      values[:enabledEnvironments].include?('development')
    end

    path 'environments.development' do
      contract EnvironmentContract, check: :require_development?
    end
  end
end

class EnvironmentContract < ApplicationContract
  params do
  end

  compose do
    # i can't figure out how to make this be an array of this contract
    path 'config[]' do
      contract EnvironmentConfigContract
    end
  end
end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment