diff --git a/Gemfile b/Gemfile index 19381a8..1b61d4a 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,7 @@ end group :development, :test do gem "activerecord" + gem "dry-validation" gem "rom-sql" gem "sequel" gem "sqlite3" diff --git a/docsite/source/extensions.html.md b/docsite/source/extensions.html.md index f7602da..f3f424c 100644 --- a/docsite/source/extensions.html.md +++ b/docsite/source/extensions.html.md @@ -169,3 +169,142 @@ end ``` ⚠️ Warning: The `:requires_new` option for nested transactions is not yet fully supported. + +### Params + +The `Params` extension adds input validation support to your operations using [dry-validation](https://dry-rb.org/gems/dry-validation/). When an operation is called, the input will be automatically validated against the defined rules before the operation logic executes. If validation fails, the operation returns a `Failure` with detailed error information without executing the operation body. + +Make sure you have dry-validation installed: + +```ruby +gem "dry-validation" +``` + +Require and include the extension in your operation class, then define validation rules using the `params` class method: + +```ruby +require "dry/operation/extensions/params" + +class CreateUser < Dry::Operation + include Dry::Operation::Extensions::Params + + params do + required(:name).filled(:string) + required(:email).filled(:string) + optional(:age).maybe(:integer) + end + + def call(input) + user = step create_user(input) + step notify(user) + user + end + + # ... +end +``` + +When validation succeeds, the operation receives the validated and coerced input: + +```ruby +result = CreateUser.new.call(name: "Alice", email: "alice@example.com", age: "25") +# => Success(user) with age coerced to integer 25 +``` + +When validation fails, the operation returns a `Failure` tagged with `:invalid_params` and the validation errors, without executing any of the operation's steps: + +```ruby +result = CreateUser.new.call(name: "", email: "invalid") +# => Failure[:invalid_params, {name: ["must be filled"]}] +``` + +#### Using params classes + +You can also pass a pre-defined params class to `params` instead of a block, which is useful for reusing validation rules across multiple operations: + +```ruby +class UserParams < Dry::Operation::Extensions::Params::Params + params do + required(:name).filled(:string) + required(:email).filled(:string) + optional(:age).maybe(:integer) + end +end + +class CreateUser < Dry::Operation + include Dry::Operation::Extensions::Params + + params UserParams + + def call(input) + user = step create_user(input) + step notify(user) + user + end + + # ... +end + +class UpdateUser < Dry::Operation + include Dry::Operation::Extensions::Params + + params UserParams + + def call(input) + # ... + end +end +``` + +#### Using contract for custom validation rules + +For more complex validation scenarios, use the `contract` method which provides access to the full dry-validation contract API, including custom rules: + +```ruby +class CreateUser < Dry::Operation + include Dry::Operation::Extensions::Params + + contract do + params do + required(:name).filled(:string) + required(:age).filled(:integer) + end + + rule(:age) do + key.failure("must be 18 or older") if value < 18 + end + end + + def call(input) + # ... + end +end +``` + +#### Custom wrapped methods + +The `params` extension works seamlessly with custom wrapped methods when using `.operate_on`: + +```ruby +class ProcessData < Dry::Operation + include Dry::Operation::Extensions::Params + + operate_on :process, :transform + + params do + required(:value).filled(:string) + end + + def process(input) + input[:value].upcase + end + + def transform(input) + input[:value].downcase + end +end +``` + +#### Inheritance + +Params classes are inherited by subclasses, allowing you to build operation hierarchies with shared validation rules. diff --git a/lib/dry/operation/extensions/params.rb b/lib/dry/operation/extensions/params.rb new file mode 100644 index 0000000..0a45965 --- /dev/null +++ b/lib/dry/operation/extensions/params.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +begin + require "dry/validation" +rescue LoadError + raise Dry::Operation::MissingDependencyError.new(gem: "dry-validation", extension: "Params") +end + +module Dry + class Operation + module Extensions + # Add params validation support to operations using dry-validation + # + # When this extension is included, you can use class-level `params` and `contract` + # methods to define validation rules for your operation's inputs. + # + # @see https://dry-rb.org/gems/dry-validation/ + module Params + # Base params class for operation input validation + class Params + # Base validator contract that all param schemas inherit from + class Validator < Dry::Validation::Contract + end + + class << self + # Define validation rules using params DSL + # + # @yield Block for defining validation rules + # @return [Validator] The validator instance + def params(&block) + @_validator = Class.new(Validator) do + params(&block) + end.new + end + + # Define validation rules using full contract DSL + # + # @yield Block for defining contract rules + # @return [Validator] The validator instance + def contract(&block) + @_validator = Class.new(Validator, &block).new + end + + # @api private + attr_reader :_validator + end + end + + # Constant for anonymous params class name + PARAMS_CLASS_NAME = "Params" + + def self.included(klass) + klass.extend(ClassMethods) + klass.prepend(InstanceMethods) + end + + # Instance methods for params validation + module InstanceMethods + include Dry::Monads::Result::Mixin + + # Validates input against the params validator + # + # @param input [Hash] The input to validate + # @return [Dry::Monads::Result] Success with validated params or Failure with errors + # @api private + def validate_params(input) + params_class = self.class._params_class + + return Success(input) unless params_class + + validator = params_class._validator + + return Success(input) unless validator + + result = validator.call(input) + + if result.success? + Success(result.to_h) + else + Failure[:invalid_params, result.errors.to_h] + end + end + end + + # Class methods added to the operation class + module ClassMethods + # Define params validation for the operation + # + # @param klass [Class, nil] A Params subclass to use + # @yield Block for defining validation rules + # @return [Class] The params class + def params(klass = nil, &block) + if klass.nil? + klass = const_set(PARAMS_CLASS_NAME, Class.new(Params)) + klass.params(&block) + end + + @_params_class = klass + _apply_params_validation + end + + # Define contract validation for the operation + # + # @param klass [Class, nil] A Params subclass to use + # @yield Block for defining contract rules + # @return [Class] The params class + def contract(klass = nil, &block) + if klass.nil? + klass = const_set(PARAMS_CLASS_NAME, Class.new(Params)) + klass.contract(&block) + end + + @_params_class = klass + _apply_params_validation + end + + # @api private + def _params_class + @_params_class + end + + # @api private + def _params_validated_methods + @_params_validated_methods ||= [] + end + + # @api private + def _apply_params_validation + methods_to_wrap = instance_variable_get(:@_prepend_manager) + &.instance_variable_get(:@methods_to_prepend) || [] + + methods_to_wrap.each do |method_name| + next if _params_validated_methods.include?(method_name) + next unless instance_methods.include?(method_name) + + prepend(Extensions::Params.create_validator_for(method_name)) + _params_validated_methods << method_name + end + end + + # @api private + def method_added(method_name) + if @_params_class + methods_to_wrap = instance_variable_get(:@_prepend_manager) + &.instance_variable_get(:@methods_to_prepend) || [] + + if methods_to_wrap.include?(method_name) && !_params_validated_methods.include?(method_name) + prepend(Extensions::Params.create_validator_for(method_name)) + _params_validated_methods << method_name + end + end + + super + end + + # @api private + def inherited(subclass) + super + if defined?(@_params_class) && @_params_class + subclass.instance_variable_set(:@_params_class, @_params_class) + end + end + end + + # @api private + def self.create_validator_for(method_name) + Module.new do + define_method(method_name) do |input = {}, *rest, **kwargs, &block| + use_kwargs = input.empty? && !kwargs.empty? && rest.empty? + actual_input = use_kwargs ? kwargs : input + + validation_result = validate_params(actual_input) + + case validation_result + when Dry::Monads::Success + validated_input = validation_result.value! + + if use_kwargs + super(**validated_input, &block) + else + super(validated_input, *rest, **kwargs, &block) + end + when Dry::Monads::Failure + throw_failure(validation_result) + end + end + end + end + end + end + end +end diff --git a/spec/integration/extensions/params_spec.rb b/spec/integration/extensions/params_spec.rb new file mode 100644 index 0000000..53f4fce --- /dev/null +++ b/spec/integration/extensions/params_spec.rb @@ -0,0 +1,306 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Dry::Operation::Extensions::Params do + include Dry::Monads[:result] + + describe "validating operation inputs" do + it "validates params and allows operation to proceed on success" do + create_user = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params do + required(:name).filled(:string) + required(:email).filled(:string) + optional(:age).maybe(:integer) + end + + def call(input) + user = step create_user_record(input) + step send_welcome_email(user) + user + end + + private + + def create_user_record(attrs) + Success(attrs.merge(id: 1)) + end + + def send_welcome_email(_user) + Success(true) + end + end + + result = create_user.new.call(name: "John Doe", email: "john@example.com", age: 25) + + expect(result).to be_success + expect(result.value!).to eq(id: 1, name: "John Doe", email: "john@example.com", age: 25) + end + + it "returns validation failure before executing operation logic" do + executed_steps = [] + + create_user = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params do + required(:name).filled(:string) + required(:email).filled(:string) + end + + define_method(:call) do |input| + executed_steps << :call_started + user = step create_user_record(input) + executed_steps << :user_created + user + end + + define_method(:create_user_record) do |attrs| + executed_steps << :create_user_record + Success(attrs.merge(id: 1)) + end + end + + result = create_user.new.call(name: "", email: "invalid") + + expect(result).to be_failure + expect(result.failure).to eq([:invalid_params, {name: ["must be filled"]}]) + expect(executed_steps).to be_empty + end + + it "coerces input values according to schema" do + calculate = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params do + required(:x).value(:integer) + required(:y).value(:integer) + end + + def call(input) + input[:x] + input[:y] + end + end + + result = calculate.new.call(x: "10", y: "20") + + expect(result).to be_success + expect(result.value!).to eq(30) + end + end + + describe "with nested schemas" do + it "validates nested structures" do + create_order = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params do + required(:customer).hash do + required(:name).filled(:string) + required(:email).filled(:string) + end + required(:items).array(:hash) do + required(:product_id).filled(:integer) + required(:quantity).filled(:integer) + end + end + + def call(input) + input + end + end + + result = create_order.new.call( + customer: {name: "John", email: "john@example.com"}, + items: [ + {product_id: 1, quantity: 2}, + {product_id: 2, quantity: 1} + ] + ) + + expect(result).to be_success + end + + it "returns detailed validation errors for nested structures" do + create_order = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params do + required(:customer).hash do + required(:name).filled(:string) + required(:email).filled(:string) + end + end + + def call(input) + input + end + end + + result = create_order.new.call(customer: {name: "", email: ""}) + + expect(result).to be_failure + failure_type, errors = result.failure + expect(failure_type).to eq(:invalid_params) + expect(errors[:customer]).to include(:name, :email) + end + end + + describe "with custom methods via operate_on" do + it "validates params for custom wrapped methods" do + processor = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + operate_on :process, :transform + + params do + required(:value).filled(:string) + end + + def process(input) + input[:value].upcase + end + + def transform(input) + input[:value].downcase + end + end + + instance = processor.new + + result = instance.process(value: "hello") + expect(result).to eq(Success("HELLO")) + + result = instance.transform(value: "WORLD") + expect(result).to eq(Success("world")) + + result = instance.process(value: "") + expect(result).to be_failure + expect(result.failure).to eq([:invalid_params, {value: ["must be filled"]}]) + end + end + + describe "with params classes" do + it "accepts a pre-defined params class" do + user_params = Class.new(Dry::Operation::Extensions::Params::Params) do + params do + required(:name).filled(:string) + required(:email).filled(:string) + optional(:age).maybe(:integer) + end + end + + create_user = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params user_params + + def call(input) + input + end + end + + result = create_user.new.call(name: "Alice", email: "alice@example.com") + expect(result).to be_success + expect(result.value!).to include(name: "Alice", email: "alice@example.com") + + result = create_user.new.call(name: "", email: "invalid") + expect(result).to be_failure + expect(result.failure.first).to eq(:invalid_params) + end + + it "allows params class reuse across multiple operations" do + shared_params = Class.new(Dry::Operation::Extensions::Params::Params) do + params do + required(:user_id).filled(:integer) + required(:action).filled(:string) + end + end + + audit_operation = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params shared_params + + def call(input) + "Audited: #{input[:action]} by user #{input[:user_id]}" + end + end + + log_operation = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params shared_params + + def call(input) + "Logged: #{input[:action]} by user #{input[:user_id]}" + end + end + + result = audit_operation.new.call(user_id: 1, action: "login") + expect(result).to be_success + expect(result.value!).to eq("Audited: login by user 1") + + result = log_operation.new.call(user_id: 2, action: "logout") + expect(result).to be_success + expect(result.value!).to eq("Logged: logout by user 2") + end + end + + describe "with contract" do + it "validates with custom rules" do + create_user = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + contract do + params do + required(:name).filled(:string) + required(:age).filled(:integer) + end + + rule(:age) do + key.failure("must be 18 or older") if value < 18 + end + end + + def call(input) + input + end + end + + result = create_user.new.call(name: "Alice", age: 25) + expect(result).to be_success + + result = create_user.new.call(name: "Bob", age: 16) + expect(result).to be_failure + expect(result.failure).to eq([:invalid_params, {age: ["must be 18 or older"]}]) + end + end + + describe "inheritance" do + it "inherits params class from parent" do + base_operation = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params do + required(:name).filled(:string) + end + end + + child_operation = Class.new(base_operation) do + def call(input) + "Hello, #{input[:name]}!" + end + end + + result = child_operation.new.call(name: "Alice") + expect(result).to be_success + expect(result.value!).to eq("Hello, Alice!") + + result = child_operation.new.call(name: "") + expect(result).to be_failure + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3c102e1..fdb54c0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -7,6 +7,7 @@ require "dry/operation" require "dry/operation/extensions/active_record" +require "dry/operation/extensions/params" require "dry/operation/extensions/rom" require "dry/operation/extensions/sequel" diff --git a/spec/unit/extensions/params_spec.rb b/spec/unit/extensions/params_spec.rb new file mode 100644 index 0000000..00c0d5a --- /dev/null +++ b/spec/unit/extensions/params_spec.rb @@ -0,0 +1,282 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Dry::Operation::Extensions::Params do + include Dry::Monads[:result] + + describe ".params" do + it "creates an anonymous Params class with validation" do + klass = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params do + required(:name).filled(:string) + end + end + + expect(klass._params_class).to be_a(Class) + expect(klass._params_class.superclass).to eq(Dry::Operation::Extensions::Params::Params) + expect(klass._params_class._validator).to be_a(Dry::Validation::Contract) + end + + it "accepts a Params class" do + params_class = Class.new(Dry::Operation::Extensions::Params::Params) do + params do + required(:name).filled(:string) + end + end + + klass = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params params_class + end + + expect(klass._params_class).to eq(params_class) + end + + it "allows params class to be inherited by subclasses" do + parent = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params do + required(:name).filled(:string) + end + end + + child = Class.new(parent) + + expect(child._params_class).to eq(parent._params_class) + end + + it "allows subclass to override parent params class" do + parent = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params do + required(:name).filled(:string) + end + end + + child = Class.new(parent) do + params do + required(:email).filled(:string) + end + end + + expect(child._params_class).not_to eq(parent._params_class) + end + end + + describe ".contract" do + it "creates an anonymous Params class with contract validation" do + klass = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + contract do + params do + required(:name).filled(:string) + end + + rule(:name) do + key.failure("must be uppercase") unless value.upcase == value + end + end + end + + expect(klass._params_class).to be_a(Class) + expect(klass._params_class._validator).to be_a(Dry::Validation::Contract) + end + + it "accepts a Params class" do + params_class = Class.new(Dry::Operation::Extensions::Params::Params) do + contract do + params do + required(:name).filled(:string) + end + end + end + + klass = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + contract params_class + end + + expect(klass._params_class).to eq(params_class) + end + end + + describe "#validate_params" do + it "returns Success with input when no params class is defined" do + klass = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + end + + instance = klass.new + result = instance.validate_params(name: "John") + + expect(result).to eq(Success(name: "John")) + end + + it "returns Success with validated params when validation passes" do + klass = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params do + required(:name).filled(:string) + end + end + + instance = klass.new + result = instance.validate_params(name: "John") + + expect(result).to eq(Success(name: "John")) + end + + it "returns Failure with errors when validation fails" do + klass = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params do + required(:name).filled(:string) + end + end + + instance = klass.new + result = instance.validate_params(name: "") + + expect(result).to be_a(Dry::Monads::Failure) + expect(result.failure).to eq([:invalid_params, {name: ["must be filled"]}]) + end + + it "coerces values according to schema" do + klass = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params do + required(:age).value(:integer) + end + end + + instance = klass.new + result = instance.validate_params(age: "25") + + expect(result).to eq(Success(age: 25)) + end + + it "validates contract rules" do + klass = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + contract do + params do + required(:name).filled(:string) + end + + rule(:name) do + key.failure("must be uppercase") unless value.upcase == value + end + end + end + + instance = klass.new + + result = instance.validate_params(name: "JOHN") + expect(result).to eq(Success(name: "JOHN")) + + result = instance.validate_params(name: "john") + expect(result).to be_a(Dry::Monads::Failure) + expect(result.failure).to eq([:invalid_params, {name: ["must be uppercase"]}]) + end + end + + describe "method wrapping" do + it "validates params before calling the method" do + klass = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params do + required(:name).filled(:string) + end + + def call(input) + input + end + end + + instance = klass.new + result = instance.call(name: "John") + + expect(result).to eq(Success(name: "John")) + end + + it "returns validation failure without executing method body" do + executed = false + + klass = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + params do + required(:name).filled(:string) + end + + define_method(:call) do |input| + executed = true + input + end + end + + instance = klass.new + result = instance.call(name: "") + + expect(result).to be_a(Dry::Monads::Failure) + expect(result.failure).to eq([:invalid_params, {name: ["must be filled"]}]) + expect(executed).to be(false) + end + + it "works with custom methods specified via operate_on" do + klass = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + operate_on :process + + params do + required(:name).filled(:string) + end + + def process(input) + input + end + end + + instance = klass.new + result = instance.process(name: "John") + + expect(result).to eq(Success(name: "John")) + end + + it "validates custom methods when params is defined before the method" do + klass = Class.new(Dry::Operation) do + include Dry::Operation::Extensions::Params + + operate_on :process + + params do + required(:name).filled(:string) + end + + def process(input) + input + end + end + + instance = klass.new + result = instance.process(name: "") + + expect(result).to be_a(Dry::Monads::Failure) + expect(result.failure).to eq([:invalid_params, {name: ["must be filled"]}]) + end + end +end