I get a parameter named includes as a comma-separated string, and want to validate that all its elements are within a set of allowed values. I believe, this is conceptually similar to this question, where an input string is coerced into an array of hashes.
I feel like I’m close, but something’s missing:
require "dry-schema"
module T
include Dry.Types()
CommaSepString = T.Constructor(Array) do |values|
values.split(",")
end
end
pp T::CommaSepString.("a,b,c")
#=> ["a", "b", "c"]
schema = Dry::Schema.Params do
optional(:includes).value(T::CommaSepString, included_in?: %w[users favorites])
end
pp schema.("includes" => "users,favorites")
#=> #<Dry::Schema::Result{:includes=>["users", "favorites"]} errors={:includes=>["must be one of: users, favorites"]} path=[]>
I’ve also tried the array macro:
schema = Dry::Schema.Params do
optional(:includes).array(T::CommaSepString, included_in?: %w[users favorites])
end
pp schema.("includes" => "users,favorites")
# #<Dry::Schema::Result{:includes=>"users,favorites"} errors={:includes=>["must be an array"]} path=[]>
Your problem is that you’re applying the constraint against the whole array rather than the individual elements.
Here how I solved a very similar problem
T = Dry.Types()
module T
Inclusion = String.enum('users', 'favorites')
module Params
CSVList = T::Array.of(String).constructor do |value|
if value.is_a?(::String)
value.split(',')
else
Array(value)
end
end
end
end
schema = Dry::Schema.Params do
optional(:includes).value(T::Params::CSVList.of(T::Inclusion))
end
One thing that slipped my mind is, that I don’t use
T = Dry.Types()
but
T = Dry.Types(default: :params)
Full code sample
module T
include Dry.Types(default: :params)
CSVList = T::Array.of(String).constructor do |value|
if value.is_a?(::String)
value.split(',')
else
Array(value)
end
end
end
module Lists
IncludeValues = T::String.enum('users', 'favorites')
IncludeParams = Dry::Schema.Params do
optional(:includes).value(T::CSVList.of IncludeValues)
end
end
pp Lists::IncludeParams.('includes' => 'users,favorites')
This still yields an error:
#<Dry::Schema::Result{:includes=>"users,favorites"} errors={:includes=>["must be an array"]} path=[]>
It’s because T::Params is trying to do its coercion logic before the custom constructor runs. Types can have multiple constructors, and by default you’re adding to the end of the list.
You can work around this by prepending it
CSVList = T::Array.of(String).prepend do |value|
if value.is_a?(::String)
value.split(',')
else
Array(value)
end
end
But I recommend using T::Strict::Array instead, which is more intention-revealing.
CSVList = T::Strict::Array.of(String).constructor do |value|
if value.is_a?(::String)
value.split(',')
else
Array(value)
end
end
I would recommend not using include, even though this is how it is currently demonstrated on the website. There was a discussion about this somewhere.
The downside of include is that if you open your own Params module etc you will replace the original, whereas with this form:
T = Dry.Types(default: :params)
module T
module Params
CSVList = T::Array.of(String).prepend do |value|
if value.is_a?(::String)
value.split(',')
else
Array(value)
end
end
end
end
Params reopens the Dry module. I do this because Strict, Params etc is useful information about how a type is intended to be used.