Last active
February 18, 2024 01:48
-
-
Save ianwhite/a32fcd439020ea07ed1fe3243152274f to your computer and use it in GitHub Desktop.
Spike for composable contracts in dry.rb (examples below the code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
## 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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I know this is about 5 years old now, but was wondering if this works for doing array of contracts.
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: