Skip to content

Commit

Permalink
Add contract support to actions (#453)
Browse files Browse the repository at this point in the history
  • Loading branch information
timriley authored Sep 2, 2024
1 parent 92295f3 commit 74b652e
Show file tree
Hide file tree
Showing 7 changed files with 352 additions and 85 deletions.
21 changes: 16 additions & 5 deletions lib/hanami/action.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,17 +125,28 @@ def self.params_class
@params_class || BaseParams
end

# Placeholder implementation for params class method
#
# Raises a developer friendly error to include `hanami/validations`.
# Placeholder for the `.params` method. Raises an error when the hanami-validations gem is not
# installed.
#
# @raise [NoMethodError]
#
# @api public
# @since 2.0.0
def self.params(_klass = nil)
raise NoMethodError,
"To use `params`, please add 'hanami/validations' gem to your Gemfile"
message = %(To use `.params`, please add the "hanami-validations" gem to your Gemfile)
raise NoMethodError, message
end

# Placeholder for the `.contract` method. Raises an error when the hanami-validations gem is not
# installed.
#
# @raise [NoMethodError]
#
# @api public
# @since 2.2.0
def self.contract
message = %(To use `.contract`, please add the "hanami-validations" gem to your Gemfile)
raise NoMethodError, message
end

# @overload self.append_before(*callbacks, &block)
Expand Down
82 changes: 36 additions & 46 deletions lib/hanami/action/params.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# frozen_string_literal: true

require "hanami/validations/form"

module Hanami
class Action
# A set of params requested by the client
Expand All @@ -15,7 +13,13 @@ class Action
#
# @since 0.1.0
class Params < BaseParams
include Hanami::Validations::Form
# @since 2.2.0
# @api private
class Validator < Dry::Validation::Contract
params do
optional(:_csrf_token).filled(:string)
end
end

# Params errors
#
Expand Down Expand Up @@ -107,47 +111,38 @@ def _nested_attribute(keys, key)
end
end

# This is a Hanami::Validations extension point
# Defines validations for the params, using the `params` schema of a dry-validation contract.
#
# @since 0.7.0
# @api private
def self._base_rules
lambda do
optional(:_csrf_token).filled(:str?)
end
end

# Define params validations
# @param block [Proc] the schema definition
#
# @param blk [Proc] the validations definitions
# @see https://dry-rb.org/gems/dry-validation/
#
# @api public
# @since 0.7.0
def self.params(&block)
@_validator = Class.new(Validator) { params(&block || -> {}) }.new
end

# Defines validations for the params, using a dry-validation contract.
#
# @see https://guides.hanamirb.org/validations/overview
# @param block [Proc] the contract definition
#
# @example
# class Signup < Hanami::Action
# MEGABYTE = 1024 ** 2
# @see https://dry-rb.org/gems/dry-validation/
#
# params do
# required(:first_name).filled(:str?)
# required(:last_name).filled(:str?)
# required(:email).filled?(:str?, format?: /\A.+@.+\z/)
# required(:password).filled(:str?).confirmation
# required(:terms_of_service).filled(:bool?)
# required(:age).filled(:int?, included_in?: 18..99)
# optional(:avatar).filled(size?: 1..(MEGABYTE * 3))
# end
#
# def handle(req, *)
# halt 400 unless req.params.valid?
# # ...
# end
# end
def self.params(&blk)
validations(&blk || -> {})
# @api public
# @since 2.2.0
def self.contract(&block)
@_validator = Class.new(Validator, &block).new
end

class << self
# @api private
# @since 2.2.0
attr_reader :_validator
end

# rubocop:disable Lint/MissingSuper

# Initialize the params and freeze them.
#
# @param env [Hash] a Rack env or an hash of params.
Expand All @@ -158,21 +153,16 @@ def self.params(&blk)
# @api private
def initialize(env)
@env = env
super(_extract_params)
validation = validate
@raw = _extract_params

validation = self.class._validator.call(raw)
@params = validation.to_h
@errors = Errors.new(validation.messages)
@errors = Errors.new(validation.errors.to_h)

freeze
end

# Returns raw params from Rack env
#
# @return [Hash]
#
# @since 0.3.2
def raw
@input
end
# rubocop:enable Lint/MissingSuper

# Returns structured error messages
#
Expand Down
133 changes: 111 additions & 22 deletions lib/hanami/action/validatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,34 +28,32 @@ def self.included(base)
# @since 0.1.0
# @api private
module ClassMethods
# Whitelist valid parameters to be passed to Hanami::Action#call.
# Defines a validation schema for the params passed to {Hanami::Action#call}.
#
# This feature isn't mandatory, but higly recommended for security
# reasons.
# This feature isn't mandatory, but is highly recommended for secure handling of params:
# because params come from an untrusted source, it's good practice to filter these to only
# the keys and types required for your action's use case.
#
# Because params come into your application from untrusted sources, it's
# a good practice to filter only the wanted keys that serve for your
# specific use case.
# The given block is evaluated inside a `params` schema of a `Dry::Validation::Contract`
# class. This constrains the validation to simple structure and type rules only. If you want
# to use all the features of dry-validation contracts, use {#contract} instead.
#
# Once whitelisted, the params are available as an Hash with symbols
# as keys.
# The resulting contract becomes part of a dedicated params class for the action, inheriting
# from {Hanami::Action::Params}.
#
# It accepts an anonymous block where all the params can be listed.
# It internally creates an inner class which inherits from
# Hanami::Action::Params.
#
# Alternatively, it accepts an concrete class that should inherit from
# Hanami::Action::Params.
# Instead of defining the params validation schema inline, you can alternatively provide a
# concrete params class, which should inherit from {Hanami::Action::Params}.
#
# @param klass [Class,nil] a Hanami::Action::Params subclass
# @param blk [Proc] a block which defines the whitelisted params
# @param block [Proc] the params schema definition
#
# @return void
#
# @see #contract
# @see Hanami::Action::Params
# @see https://guides.hanamirb.org//validations/overview
# @see https://dry-rb.org/gems/dry-validation/
#
# @example Anonymous Block
# @example Inline definition
# require "hanami/controller"
#
# class Signup < Hanami::Action
Expand All @@ -78,9 +76,11 @@ module ClassMethods
# require "hanami/controller"
#
# class SignupParams < Hanami::Action::Params
# required(:first_name)
# required(:last_name)
# required(:email)
# params do
# required(:first_name)
# required(:last_name)
# required(:email)
# end
# end
#
# class Signup < Hanami::Action
Expand All @@ -95,12 +95,101 @@ module ClassMethods
# end
# end
#
# @api public
# @since 0.3.0
def params(klass = nil, &block)
if klass.nil?
klass = const_set(PARAMS_CLASS_NAME, Class.new(Params))
klass.params(&block)
end

@params_class = klass
end

# Defines a validation contract for the params passed to {Hanami::Action#call}.
#
# This feature isn't mandatory, but is highly recommended for secure handling of params:
# because params come from an untrusted source, it's good practice to filter these to only
# the keys and types required for your action's use case.
#
# The given block is evaluated inside a `Dry::Validation::Contract` class. This allows you
# to use all features of dry-validation contracts
#
# The resulting contract becomes part of a dedicated params class for the action, inheriting
# from {Hanami::Action::Params}.
#
# Instead of defining the params validation contract inline, you can alternatively provide a
# concrete params class, which should inherit from {Hanami::Action::Params}.
#
# @param klass [Class,nil] a Hanami::Action::Params subclass
# @param block [Proc] the params schema definition
#
# @return void
#
# @see #params
# @see Hanami::Action::Params
# @see https://dry-rb.org/gems/dry-validation/
#
# @example Inline definition
# require "hanami/controller"
#
# class Signup < Hanami::Action
# contract do
# params do
# required(:first_name)
# required(:last_name)
# required(:email)
# end
#
# rule(:email) do
# # custom rule logic here
# end
# end
#
# def handle(req, *)
# puts req.params.class # => Signup::Params
# puts req.params.class.superclass # => Hanami::Action::Params
#
# puts req.params[:first_name] # => "Luca"
# puts req.params[:admin] # => nil
# end
# end
#
# @example Concrete class
# require "hanami/controller"
#
# class SignupParams < Hanami::Action::Params
# contract do
# params do
# required(:first_name)
# required(:last_name)
# required(:email)
# end
#
# rule(:email) do
# # custom rule logic here
# end
# end
# end
#
# class Signup < Hanami::Action
# params SignupParams
#
# def handle(req, *)
# puts req.params.class # => SignupParams
# puts req.params.class.superclass # => Hanami::Action::Params
#
# req.params[:first_name] # => "Luca"
# req.params[:admin] # => nil
# end
# end
#
# @api public
def params(klass = nil, &blk)
# @since 2.2.0
def contract(klass = nil, &block)
if klass.nil?
klass = const_set(PARAMS_CLASS_NAME, Class.new(Params))
klass.class_eval { params(&blk) }
klass.contract(&block)
end

@params_class = klass
Expand Down
17 changes: 16 additions & 1 deletion spec/isolation/without_hanami_validations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,22 @@
end
end.to raise_error(
NoMethodError,
/To use `params`, please add 'hanami\/validations' gem to your Gemfile/
%(To use `.params`, please add the "hanami-validations" gem to your Gemfile)
)
end

it "doesn't have the contract DSL" do
expect do
Class.new(Hanami::Action) do
contract do
params do
required(:id).filled
end
end
end
end.to raise_error(
NoMethodError,
%(To use `.contract`, please add the "hanami-validations" gem to your Gemfile)
)
end

Expand Down
Loading

0 comments on commit 74b652e

Please sign in to comment.