dry-rb / dry-validation

Validation library with type-safe schemas and rules
https://dry-rb.org/gems/dry-validation
MIT License
1.34k stars 188 forks source link

Validator didn't recognize nil string param value as empty, and didn't set default value #719

Closed sbezugliy closed 1 year ago

sbezugliy commented 1 year ago

Describe the bug

I using Interactor based on Validation::Contract with mixed in dry-initializer and dry-transaction. I trying to use schema for validation in one line with initializer to set default param values and convert types/formats for some of them using dry-initializer.

To Reproduce

Loaded app components using Zeitwerk, didn't use dry-container in current case.

Spec failure:

Failures:

  1) Interactors::Base64::EncodeInteractor when params are wrong 
     Failure/Error: Success(cypher: ::Base64.encode64(input[:data]))

     TypeError:
       no implicit conversion of nil into String
     # ./lib/interactors/base64/encode_interactor.rb:20:in `encode'
     # /Users/sergey_bezugliy/.rvm/gems/ruby-3.1.2@barcoder/gems/dry-transaction-0.14.0/lib/dry/transaction/callable.rb:35:in `call'
     # /Users/sergey_bezugliy/.rvm/gems/ruby-3.1.2@barcoder/gems/dry-transaction-0.14.0/lib/dry/transaction/callable.rb:35:in `call'
     # /Users/sergey_bezugliy/.rvm/gems/ruby-3.1.2@barcoder/gems/dry-transaction-0.14.0/lib/dry/transaction/step_adapters/around.rb:14:in `call'
     # /Users/sergey_bezugliy/.rvm/gems/ruby-3.1.2@barcoder/gems/dry-transaction-0.14.0/lib/dry/transaction/step_adapters/raw.rb:13:in `call'
     # /Users/sergey_bezugliy/.rvm/gems/ruby-3.1.2@barcoder/gems/dry-transaction-0.14.0/lib/dry/transaction/step_adapter.rb:43:in `call'
     # /Users/sergey_bezugliy/.rvm/gems/ruby-3.1.2@barcoder/gems/dry-transaction-0.14.0/lib/dry/transaction/step_adapter.rb:43:in `call'
     # /Users/sergey_bezugliy/.rvm/gems/ruby-3.1.2@barcoder/gems/dry-transaction-0.14.0/lib/dry/transaction/step.rb:56:in `block in call'
     # /Users/sergey_bezugliy/.rvm/gems/ruby-3.1.2@barcoder/gems/dry-transaction-0.14.0/lib/dry/transaction/step.rb:63:in `with_broadcast'
     # /Users/sergey_bezugliy/.rvm/gems/ruby-3.1.2@barcoder/gems/dry-transaction-0.14.0/lib/dry/transaction/step.rb:56:in `call'
     # /Users/sergey_bezugliy/.rvm/gems/ruby-3.1.2@barcoder/gems/dry-transaction-0.14.0/lib/dry/transaction/stack.rb:21:in `block (3 levels) in compile'
     # /Users/sergey_bezugliy/.rvm/gems/ruby-3.1.2@barcoder/gems/dry-monads-1.6.0/lib/dry/monads/right_biased.rb:52:in `bind'
     # /Users/sergey_bezugliy/.rvm/gems/ruby-3.1.2@barcoder/gems/dry-transaction-0.14.0/lib/dry/transaction/stack.rb:21:in `block (2 levels) in compile'
     # /Users/sergey_bezugliy/.rvm/gems/ruby-3.1.2@barcoder/gems/dry-transaction-0.14.0/lib/dry/transaction/stack.rb:14:in `call'
     # /Users/sergey_bezugliy/.rvm/gems/ruby-3.1.2@barcoder/gems/dry-transaction-0.14.0/lib/dry/transaction/instance_methods.rb:30:in `call'
     # ./spec/interactors/base64/encode_interactor_spec.rb:7:in `block (2 levels) in <top (required)>'
     # ./spec/interactors/base64/encode_interactor_spec.rb:15:in `block (3 levels) in <top (required)>'

Finished in 2.66 seconds (files took 1.89 seconds to load)
304 examples, 1 failure, 16 pending

Spec:

# frozen_string_literal: true

require_relative "../../spec_helper"

RSpec.describe Interactors::Base64::EncodeInteractor do
  let(:params) { { data: "data" } }
  let(:interactor) { described_class.new.call(params) }
  let(:cypher) { "ZGF0YQ==\n" }

  it { expect(interactor.success).to eq({ cypher: }) }

  context "when params are wrong" do
    let(:params) { { data: nil } }

    it { expect(interactor.failure).to eq({}) }
  end
end

Contract class:

# frozen_string_literal: true

require "base64"

module Interactors
  module Base64
    class EncodeInteractor < Dry::Validation::Contract
      extend Dry::Initializer
      include Dry::Transaction

      schema Schemas::Base64::DataSchema

      param :data, proc(&:to_s)

      step :encode

      private

      def encode(input)
        Success(cypher: ::Base64.encode64(input[:data]))
      end
    end
  end
end

Schema:

# frozen_string_literal: true

module Schemas
  module Base64
    DataSchema = Dry::Schema.Params do
      required(:data).filled(:string)
    end
  end
end

Expected behavior

  1. Expecting raise of Failure("Data should not be empty") through monade state, for {data: nil}, {data: ""}.
  2. Applying default value using at Contract with extend Dry::Initializer.

My environment

sbezugliy commented 1 year ago

The same for interactor setting default value as next

# frozen_string_literal: true

require "base64"

module Interactors
  module Base64
    class EncodeInteractor < Dry::Validation::Contract
      extend Dry::Initializer
      include Dry::Transaction

      schema Schemas::Base64::DataSchema

      param :data, default: proc { "" }

      step :encode

      private

      def encode(input)
        Success(cypher: ::Base64.encode64(input[:data]))
      end
    end
  end
end
solnic commented 1 year ago

Transaction and Contract are not meant to be used in the same class, I'm not sure if this causes the issue but I figured I should mention that. Both implement #call so this doesn't make sense.

sbezugliy commented 1 year ago

Hmm... Understood. I did find this as interesting use-case, mixing both inside of same context. I will analyze one more time, to combine them, due to I want to get thin context between serializers and backend classes, and to realise conveyor and railway approaches, with strong fault traceability.

sbezugliy commented 1 year ago

Or I will extract validators to additional layer, but wanted to make smaller count of levels. Also I don't use models, but only backend classes and libs, so implementation of validators at the level of contracts/interactors have sense for current case.

sbezugliy commented 1 year ago

And thank you for direction to solve!

sbezugliy commented 1 year ago

Solved it in this way.

schema:

# frozen_string_literal: true

module Schemas
  module Base64
    DataSchema = Dry::Schema.Params do
      required(:data).filled(:string)
    end
  end
end

contracts:

module Contracts
  class BaseContract < Dry::Validation::Contract
    include Dry::Logic
    include Dry::Monads[:result]
    Dry::Validation.load_extensions :monads
  end
end
# frozen_string_literal: true

module Contracts
  module Base64
    class EncodeContract < BaseContract
      schema Schemas::Base64::DataSchema
    end
  end
end

interactor:

# frozen_string_literal: true

module Interactors
  class BaseInteractor
    extend Dry::Initializer
    include Dry::Transaction
    include Dry::Logic
    include Dry::Monads[:result]
  end
end
# frozen_string_literal: true

require "base64"

module Interactors
  module Base64
    class EncodeInteractor < BaseInteractor
      param :data, default: proc { "" }

      step :contract
      step :encode

      private

      def contract(input)
        Contracts::Base64::EncodeContract.new.call(input).to_monad
      end

      def encode(input)
        Success(cypher: ::Base64.encode64(input[:data]))
      end
    end
  end
end

It's simples chain of interaction, but current layout is well covers my requirements for interactor of 10 steps with some branch actions. Next also I'll add layer of dry-matchers to convert dry-monades messages to formatted JSON API responses.