Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add contract support to actions #453

Merged
merged 6 commits into from
Sep 2, 2024
Merged
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
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)
parndt marked this conversation as resolved.
Show resolved Hide resolved
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
Copy link
Contributor

@alassek alassek Aug 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm conflicted about this decision. The reason why I would most likely want to use a concrete class for this is to share validation rules between multiple things.

By basing this on Hanami::Action::Params, this requires a request env to initialize, which limits its usefulness to controllers only. This excludes non-request sharing, such as Operations or CLI tools.

I wish contracts were composable. If I could merge a preexisting contract into the action validator, this would be moot.

Another way you can share logic is via macros. My preference would be to define an abstract class to contain these macros because I may want to use different ones for different use-cases. But since you hard-code Dry::Validation::Contract as the base class here, I can only share macros globally. I don't know if that's a problem.

On the other hand, perhaps its reasonable that HTTP-boundary validations are their own thing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alassek Thanks for raising this! I agree with you here. If I was building this feature from scratch today, I wouldn't have things run through these Hanami::Action::Params intermediary classes, and instead have dry-validation contracts be provided directly.

I'd describe this current approach as a vestige of the 1.x-era of Hanami design, where it was much more heavy-handed in "owning the APIs" rather than the lighter touch approach we take for our integration with the dry-rb gems today. It's also worth mentioning that back in those times, dry-validation might have seemed like a slightly less stable target, so the framework builders might've wanted this extra layer as "insulation."

In terms of why I implemented it this way here: I wanted a small, minimally-disruptive change as a first step, something I could merge confidently and use as a launching point for any next steps. I didn't say it out loud, but I did want us to get to the point of supporting dry-validation contracts directly, but I wasn't sure about when would be ideal for that.

Your question motivates me to have a quick run at it as a follow-up and see what might be possible :)

As an additional measure, I'm going to mark the new Hanami::API::Params.contract method here as @api private, as part of a "soft deprecation" approach to this direct use of the Params class. I'd much rather have users specify their validations at the action level only, and use contracts directly as a way to reuse it. Let's see how it goes!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've started this work in #454. I think I have a pretty clear path to getting it done, too. I'll leave another update over in that PR once it's ready!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#454 is ready now :)

# 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