Skip to content

Commit 729ebe1

Browse files
committed
Add Params extension with unit tests
Adds input validation support to operations using dry-validation. This follows the Hanami controller pattern where params and contract methods create anonymous Params classes for validation. Key features: - Automatic validation of operation inputs before execution - Support for both params DSL and full contract API - Params class reusability across operations - Integration with operate_on for custom wrapped methods - Params class inheritance for operation hierarchies - Returns Failure[:invalid_params, errors] on validation failure Includes comprehensive unit tests covering validation logic, method wrapping, params class creation and inheritance, and contract validation with custom rules.
1 parent 156dcd5 commit 729ebe1

File tree

4 files changed

+476
-0
lines changed

4 files changed

+476
-0
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ end
2222

2323
group :development, :test do
2424
gem "activerecord"
25+
gem "dry-validation"
2526
gem "rom-sql"
2627
gem "sequel"
2728
gem "sqlite3"
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# frozen_string_literal: true
2+
3+
begin
4+
require "dry/validation"
5+
rescue LoadError
6+
raise Dry::Operation::MissingDependencyError.new(gem: "dry-validation", extension: "Params")
7+
end
8+
9+
module Dry
10+
class Operation
11+
module Extensions
12+
# Add params validation support to operations using dry-validation
13+
#
14+
# When this extension is included, you can use class-level `params` and `contract`
15+
# methods to define validation rules for your operation's inputs.
16+
#
17+
# @see https://dry-rb.org/gems/dry-validation/
18+
module Params
19+
# Base params class for operation input validation
20+
class Params
21+
# Base validator contract that all param schemas inherit from
22+
class Validator < Dry::Validation::Contract
23+
end
24+
25+
class << self
26+
# Define validation rules using params DSL
27+
#
28+
# @yield Block for defining validation rules
29+
# @return [Validator] The validator instance
30+
def params(&block)
31+
@_validator = Class.new(Validator) do
32+
params(&block)
33+
end.new
34+
end
35+
36+
# Define validation rules using full contract DSL
37+
#
38+
# @yield Block for defining contract rules
39+
# @return [Validator] The validator instance
40+
def contract(&block)
41+
@_validator = Class.new(Validator, &block).new
42+
end
43+
44+
# @api private
45+
attr_reader :_validator
46+
end
47+
end
48+
49+
# Constant for anonymous params class name
50+
PARAMS_CLASS_NAME = "Params"
51+
52+
def self.included(klass)
53+
klass.extend(ClassMethods)
54+
klass.prepend(InstanceMethods)
55+
end
56+
57+
# Instance methods for params validation
58+
module InstanceMethods
59+
include Dry::Monads::Result::Mixin
60+
61+
# Validates input against the params validator
62+
#
63+
# @param input [Hash] The input to validate
64+
# @return [Dry::Monads::Result] Success with validated params or Failure with errors
65+
# @api private
66+
def validate_params(input)
67+
params_class = self.class._params_class
68+
69+
return Success(input) unless params_class
70+
71+
validator = params_class._validator
72+
73+
return Success(input) unless validator
74+
75+
result = validator.call(input)
76+
77+
if result.success?
78+
Success(result.to_h)
79+
else
80+
Failure[:invalid_params, result.errors.to_h]
81+
end
82+
end
83+
end
84+
85+
# Class methods added to the operation class
86+
module ClassMethods
87+
# Define params validation for the operation
88+
#
89+
# @param klass [Class, nil] A Params subclass to use
90+
# @yield Block for defining validation rules
91+
# @return [Class] The params class
92+
def params(klass = nil, &block)
93+
if klass.nil?
94+
klass = const_set(PARAMS_CLASS_NAME, Class.new(Params))
95+
klass.params(&block)
96+
end
97+
98+
@_params_class = klass
99+
_apply_params_validation
100+
end
101+
102+
# Define contract validation for the operation
103+
#
104+
# @param klass [Class, nil] A Params subclass to use
105+
# @yield Block for defining contract rules
106+
# @return [Class] The params class
107+
def contract(klass = nil, &block)
108+
if klass.nil?
109+
klass = const_set(PARAMS_CLASS_NAME, Class.new(Params))
110+
klass.contract(&block)
111+
end
112+
113+
@_params_class = klass
114+
_apply_params_validation
115+
end
116+
117+
# @api private
118+
def _params_class
119+
@_params_class
120+
end
121+
122+
# @api private
123+
def _params_validated_methods
124+
@_params_validated_methods ||= []
125+
end
126+
127+
# @api private
128+
def _apply_params_validation
129+
methods_to_wrap = instance_variable_get(:@_prepend_manager)
130+
&.instance_variable_get(:@methods_to_prepend) || []
131+
132+
methods_to_wrap.each do |method_name|
133+
next if _params_validated_methods.include?(method_name)
134+
next unless instance_methods.include?(method_name)
135+
136+
prepend(Extensions::Params.create_validator_for(method_name))
137+
_params_validated_methods << method_name
138+
end
139+
end
140+
141+
# @api private
142+
def method_added(method_name)
143+
if @_params_class
144+
methods_to_wrap = instance_variable_get(:@_prepend_manager)
145+
&.instance_variable_get(:@methods_to_prepend) || []
146+
147+
if methods_to_wrap.include?(method_name) && !_params_validated_methods.include?(method_name)
148+
prepend(Extensions::Params.create_validator_for(method_name))
149+
_params_validated_methods << method_name
150+
end
151+
end
152+
153+
super
154+
end
155+
156+
# @api private
157+
def inherited(subclass)
158+
super
159+
if defined?(@_params_class) && @_params_class
160+
subclass.instance_variable_set(:@_params_class, @_params_class)
161+
end
162+
end
163+
end
164+
165+
# @api private
166+
def self.create_validator_for(method_name)
167+
Module.new do
168+
define_method(method_name) do |input = {}, *rest, **kwargs, &block|
169+
use_kwargs = input.empty? && !kwargs.empty? && rest.empty?
170+
actual_input = use_kwargs ? kwargs : input
171+
172+
validation_result = validate_params(actual_input)
173+
174+
case validation_result
175+
when Dry::Monads::Success
176+
validated_input = validation_result.value!
177+
178+
if use_kwargs
179+
super(**validated_input, &block)
180+
else
181+
super(validated_input, *rest, **kwargs, &block)
182+
end
183+
when Dry::Monads::Failure
184+
throw_failure(validation_result)
185+
end
186+
end
187+
end
188+
end
189+
end
190+
end
191+
end
192+
end

spec/spec_helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
require "dry/operation"
99
require "dry/operation/extensions/active_record"
10+
require "dry/operation/extensions/params"
1011
require "dry/operation/extensions/rom"
1112
require "dry/operation/extensions/sequel"
1213

0 commit comments

Comments
 (0)