Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ end

group :development, :test do
gem "activerecord"
gem "dry-validation"
gem "rom-sql"
gem "sequel"
gem "sqlite3"
Expand Down
139 changes: 139 additions & 0 deletions docsite/source/extensions.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "[email protected]", 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.
192 changes: 192 additions & 0 deletions lib/dry/operation/extensions/params.rb
Original file line number Diff line number Diff line change
@@ -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
Loading