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

Feature/action contracts #451

Closed
wants to merge 12 commits into from
19 changes: 16 additions & 3 deletions lib/hanami/action.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def self.gem_loader
loader.ignore(
"#{root}/hanami-controller.rb",
"#{root}/hanami/controller/version.rb",
"#{root}/hanami/action/{constants,errors,params,validatable}.rb"
"#{root}/hanami/action/{constants,errors,params,contract,validatable}.rb"
)
loader.inflector.inflect("csrf_protection" => "CSRFProtection")
end
Expand Down Expand Up @@ -138,6 +138,19 @@ def self.params(_klass = nil)
"To use `params`, please add 'hanami/validations' gem to your Gemfile"
end

# Placeholder implementation for contract class method
#
# Raises a developer friendly error to include `hanami/validations`.
#
# @raise [NoMethodError]
#
# @api public
# @since 2.2.0
def self.contract
raise NoMethodError,
"To use `contract`, please add 'hanami/validations' gem to your Gemfile"
end

# @overload self.append_before(*callbacks, &block)
# Define a callback for an Action.
# The callback will be executed **before** the action is called, in the
Expand Down Expand Up @@ -305,8 +318,8 @@ def call(env)
response = nil

halted = catch :halt do
params = self.class.params_class.new(env)
request = build_request(
params = self.class.params_class.new(env)
request = build_request(
env: env,
params: params,
session_enabled: session_enabled?
Expand Down
76 changes: 3 additions & 73 deletions lib/hanami/action/base_params.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,13 @@ class Action
# @api private
# @since 0.7.0
class BaseParams
include Hanami::Action::RequestParams::Base
# @attr_reader env [Hash] the Rack env
#
# @since 0.7.0
# @api private
attr_reader :env

# @attr_reader raw [Hash] the raw params from the request
#
# @since 0.7.0
# @api private
attr_reader :raw

# Returns a new frozen params object for the Rack env.
#
# @param env [Hash] a Rack env or an hash of params.
Expand All @@ -39,23 +34,11 @@ class BaseParams
# @api private
def initialize(env)
@env = env
@raw = _extract_params
@params = Utils::Hash.deep_symbolize(@raw)
@input = Hanami::Action::ParamsExtraction.new(env).call
@params = Utils::Hash.deep_symbolize(@input)
freeze
end

# Returns the value for the given params key.
#
# @param key [Symbol] the key
#
# @return [Object,nil] the associated value, if found
#
# @since 0.7.0
# @api public
def [](key)
@params[key]
end

# Returns an value associated with the given params key.
#
# You can access nested attributes by listing all the keys in the path. This uses the same key
Expand Down Expand Up @@ -86,17 +69,6 @@ def [](key)
# end
# end
#
# @since 0.7.0
# @api public
def get(*keys)
@params.dig(*keys)
end

# This is for compatibility with Hanami::Helpers::FormHelper::Values
#
# @api private
# @since 0.8.0
alias_method :dig, :get

# Returns true at all times, providing a common interface with {Params}.
#
Expand All @@ -110,50 +82,8 @@ def valid?
true
end

# Returns a hash of the parsed request params.
#
# @return [Hash]
#
# @since 0.7.0
# @api public
def to_h
@params
end
alias_method :to_hash, :to_h

# Iterates over the params.
#
# Calls the given block with each param key-value pair; returns the full hash of params.
#
# @yieldparam key [Symbol]
# @yieldparam value [Object]
#
# @return [to_h]
#
# @since 0.7.1
# @api public
def each(&blk)
to_h.each(&blk)
end

private

# @since 0.7.0
# @api private
def _extract_params
result = {}

if env.key?(Action::RACK_INPUT)
result.merge! ::Rack::Request.new(env).params
result.merge! _router_params
else
result.merge! _router_params(env)
env[Action::REQUEST_METHOD] ||= Action::DEFAULT_REQUEST_METHOD
end

result
end

# @since 0.7.0
# @api private
def _router_params(fallback = {})
Expand Down
81 changes: 81 additions & 0 deletions lib/hanami/action/contract.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# frozen_string_literal: true

module Hanami
class Action
# A wrapper for defining validation rules using Dry::Validation. This class essentially
# wraps a Dry::Validation::Contract and acts as a proxy to actually use Dry gem
#
# Defined via the `contract` block in an action class.
# Although more complex domain-specific validations, or validations concerned with things such as uniqueness
# are usually better performed at layers deeper than your HTTP actions, Contract still provides helpful features
# that you can use without contravening the advice form above.
#
# @since 2.2.0
class Contract
Copy link
Member

@timriley timriley Aug 7, 2024

Choose a reason for hiding this comment

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

So if I trace along the new logic in the PR:

In an action where a user defines a contract with contract do, then an instance of Hanami::Action::Contract will become the params object that we provide to the handle method as part of the request.

Am I understanding this correctly?

If so, since this params object can now be an instance of either Hanami::Action::Contract or Hanami::Action::Params, I think we want to make sure they have the same interface.

Why is this? I think it would be perfectly acceptable for a user within a single app to have params do in some action classes, and contract do in others. So this means that across a single app, the user will get different kinds of params instances, but that user should expect those objects to behave identically.

Right now Hanami::Action::Params implements a few things that the new Hanami::Action::Contract does not:

  • #raw
  • #error_messages
  • #deconstruct_keys
  • #get, #dig (via BaseParams)
  • #each (via BaseParams)

We need to make these consistent before we can be done with this PR.

I reckon we have a couple of options to consider here:

  1. We could expand the Hanami::Action::Contract so that it has the same interface as Params and BaseParams
  2. Or I wonder if there's a way we can have a single class provide this "params" interface to the user, and have it be provided the "validation" object (which is either a dry-validation contract or a dry-schema schema)? This way we can stick with a single class for params and not have to maintain two parallel implementations.

I think I like option (2) better. But I'd be keen for your thoughts!

Copy link
Member

Choose a reason for hiding this comment

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

Option 2 sounds much better

Copy link
Author

Choose a reason for hiding this comment

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

Well going with option 1 we would have easier time to change either of the implementation if there is ever a need for it, change of approach.

But for now it makes no sense to "guess" that it might happen, so I like option 2 better too.

I'll try to implement it in the next couple of days.

Thanks for this I completly missed those few methods I did wanted them to have interface as close as possible.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks @krzykamil! Looking forward to seeing how you go, and of course I'm happy to provide any feedback you might need along the way.

Copy link
Author

Choose a reason for hiding this comment

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

@timriley I've pushed the changes in the last two commits.

The idea is:

Common functionality/interface can be divided into two modules:

  1. Validations handling
  2. Base functions

So I created two modules, included in BaseParams and ContractClass (not in Params since it inherits from BaseParams). One of them includes the conversion methods like to_h, and getters like dig, get [] etc. Those are the base functions.
The other module handles the validations parts, extracting params, handling and parsing errors, the more specific stuff connected to Dry::Validation. It also sets the same way to initialize both Params and Contract classes.

This left both Contract and Params classes as rather slim
Params only has
self._base_rules
self.params(&blk)

Those two class methods left. Contract respectiely has self.contract but also some extra stuff that Params class gets from the hanami validations gem, that I could not get around.

Also take note of the _base_rules, I did not implement an adequate method for Contract class, as I wasn't sure if we should have that there and if yes, what is the best way to do it (would appreciate a suggestion)

Anyway, let me know if this approach is OK, what else can be modified and improved.

I've copied a lot of specs from Params specs, to ensure their interfaces are indeed the same and they react similarly to similar operations

include Hanami::Action::RequestParams::ActionValidations
include Hanami::Action::RequestParams::Base
# A wrapper for the result of a contract validation
# @since 2.2.0
# @api private
class Result < SimpleDelegator
# @since 2.0.0
# @api private
def to_h
__getobj__.to_h
end
krzykamil marked this conversation as resolved.
Show resolved Hide resolved

# This method is called messages not errors to be consistent with the Hanami::Validations::Result
#
# @return [Hash] the error messages
#
# @since 2.0.0
# @api private
def messages
__getobj__.errors.to_h
end
krzykamil marked this conversation as resolved.
Show resolved Hide resolved
end

# Define a contract for the given action
#
# @param blk [Proc] the block to define the contract, including [Params] as a contract schema and connected rules
#
# @since 2.2.0
# @api private
#
# @example
# class Create < Hanami::Action
# contract do
# params do
# required(:birth_date).value(:date)
# end
# rule(:birth_date) do
# key.failure('you must be 18 years or older to register') if value > Date.today - 18.years
# end
# end
#
# def handle(req, *)
# halt 400 unless req.contract.call.errors.empty?
# # ...
# end
# end
def self.contract(&blk)
@_validator = Dry::Validation::Contract.build(&blk)
end

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

private

# @since 2.2.0
def validate
Result.new(
self.class._validator.call(@input)
)
end
end
end
end
Loading