Reuse schema validation with inheritance

Currently, I am using dry-validation as request parameters validators in my rails app, so the data flow:

controller action -> validate request params (using dry-validation) -> post-processing output params -> domain objects

Because my request payload is based on jsonapi.org spec, so I just wondering is there any way to setup parent and child relationship to inherit schema from the parent. Please look at example below:

class JSONAPIResourceSchema < Dry::Validation::Contract
  params do
    required(:data).schema do
      required(:type).filled(:string)
      required(:attributes).schema do
        ### yield child schema ###
      end
    end
  end
end

class UserSchema < JSONAPIResourceSchema
  params do
    required(:name).filled(:string)
  end
end

UserSchema.new.call(data: { type: "users", attributes: { name: "John Doe" } })

You can just do this:

require 'dry/validation'

class UserSchema < Dry::Schema::Params
  define do
    required(:name).filled(:string)
  end
end

class MyContract < Dry::Validation::Contract
  params do
    required(:attributes).hash(UserSchema.new)
  end
end

my_contract = MyContract.new

my_contract.call(attributes: { name: "" }).errors.to_h
# {:attributes=>{:name=>["must be filled"]}}

We may introduce an API to hide the details in cases like that, but this will work for now.

Thanks for your suggestion, then it will have 1 schema and 1 contract to represent one request object.
I have ended up with a solution with inheritance and override params methods:

class JSONAPIRequestSchema < ApplicationRequestSchema
  def self.params(resource_type, &block)
    super() do
      required(:data).schema do
        required(:type).filled(:str?, eql?: resource_type.to_s)
        required(:attributes).hash do
          instance_eval(&block)
        end
      end
    end
  end

  def self.rule(*args, &block)
    args = args.map { |arg| "data.attributes.#{arg}" }
    super(*args) do
      attributes = values.dig(:data, :attributes)
      instance_exec(attributes, &block) if block_given?
    end
  end
end

class ChargeRequestSchema < JSONAPIRequestSchema
  params(:charge) do
    required(:amount).type(:integer).filled(:int?, gteq?: 100, lteq?: 99999999)
    required(:currency).value(included_in?: %w[usd USD])
  end
end

I just curious what do you think for this solution @solnic?

@samnang this looks like a good solution for now. It would be good to have an API for this kind of use cases in the future though.

One thing to mention that’s unrelated to inheritance, is that it’s recommended to use rules for domain validation like gteq?: 100 etc. For now you can use use rule but in 1.2.0 we’ll add an extension that will define macros for all the built-in predicates, so you’ll be able to do things like:

class ChargeRequestSchema < JSONAPIRequestSchema
  params(:charge) do
    required(:amount).filled(:integer)
    required(:currency).filled(:string)
  end

  rule(:amount).validate(gteq?: 100, lteq?: 99999999) 
  rule(:currency).validate(included_in?: %w[usd USD])
end

oh and one more thing:

# this
required(:amount).type(:integer).filled(:int?, ...)

# can be simplified as this
required(:amount).filled(:integer, ...)

Hi! Is there also a way to include a schema (in this case UserSchema) in a contract (MyContract) without the attributes param?

Something like this:

class MyContract < Dry::Validation::Contract
  params do
    UserSchema.new
  end
end

so that I can call it like MyContract.new.call(name: "").errors

Not yet. There’s an issue about it in dry-schema. It’ll be added later.

1 Like