# frozen_string_literal: true require "rack/request" require "hanami/utils/hash" module Hanami class Action # Provides access to params included in a Rack request. # # Offers useful access to params via methods like {#[]}, {#get} and {#to_h}. # # These params are available via {Request#params}. # # This class is used by default when {Hanami::Action::Validatable} is not included, or when no # {Validatable::ClassMethods#params params} validation schema is defined. # # @see Hanami::Action::Request#params # A set of params requested by the client # # It's able to extract the relevant params from a Rack env of from an Hash. # # There are three scenarios: # * When used with Hanami::Router: it contains only the params from the request # * When used standalone: it contains all the Rack env # * Default: it returns the given hash as it is. It's useful for testing purposes. # # @since 0.1.0 class Params # Permits all params and returns them as symbolized keys. Stands in for a # `Dry::Validation::Contract` when neither {Action.params} nor {Action.contract} are called. # # @see {Params#initialize} # # @since 2.2.0 # @api private class DefaultContract def self.call(attrs) = Result.new(attrs) class Result def initialize(attrs) = @attrs = Utils::Hash.deep_symbolize(attrs) def to_h = @attrs def errors = {} end end # Params errors # # @since 1.1.0 class Errors < SimpleDelegator # @since 1.1.0 # @api private def initialize(errors = {}) super(errors.dup) end # Add an error to the param validations # # This has a semantic similar to `Hash#dig` where you use a set of keys # to get a nested value, here you use a set of keys to set a nested # value. # # @param args [Array] an array of arguments: the last # one is the message to add (String), while the beginning of the array # is made of keys to reach the attribute. # # @raise [ArgumentError] when try to add a message for a key that is # already filled with incompatible message type. # This usually happens with nested attributes: if you have a `:book` # schema and the input doesn't include data for `:book`, the messages # will be `["is missing"]`. In that case you can't add an error for a # key nested under `:book`. # # @since 1.1.0 # # @example Basic usage # require "hanami/controller" # # class MyAction < Hanami::Action # params do # required(:book).schema do # required(:isbn).filled(:str?) # end # end # # def handle(req, res) # # 1. Don't try to save the record if the params aren't valid # return unless req.params.valid? # # BookRepository.new.create(req.params[:book]) # rescue Hanami::Model::UniqueConstraintViolationError # # 2. Add an error in case the record wasn't unique # req.params.errors.add(:book, :isbn, "is not unique") # end # end # # @example Invalid argument # require "hanami/controller" # # class MyAction < Hanami::Action # params do # required(:book).schema do # required(:title).filled(:str?) # end # end # # def handle(req, *) # puts req.params.to_h # => {} # puts req.params.valid? # => false # puts req.params.error_messages # => ["Book is missing"] # puts req.params.errors # => {:book=>["is missing"]} # # req.params.errors.add(:book, :isbn, "is not unique") # => ArgumentError # end # end def add(*args) *keys, key, error = args _nested_attribute(keys, key) << error rescue TypeError raise ArgumentError.new("Can't add #{args.map(&:inspect).join(', ')} to #{inspect}") end private # @since 1.1.0 # @api private def _nested_attribute(keys, key) if keys.empty? self else keys.inject(self) { |result, k| result[k] ||= {} } dig(*keys) end[key] ||= [] end end # Defines validations for the params, using the `params` schema of a dry-validation contract. # # @param block [Proc] the schema definition # # @see https://dry-rb.org/gems/dry-validation/ # # @api public # @since 0.7.0 def self.params(&block) unless defined?(Dry::Validation::Contract) message = %(To use `.params`, please add the "hanami-validations" gem to your Gemfile) raise NoMethodError, message end @_contract = Class.new(Dry::Validation::Contract) { params(&block || -> {}) }.new end class << self # @api private # @since 2.2.0 attr_reader :_contract end # @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 structured error messages # # @return [Hash] # # @since 0.7.0 # # @example # params.errors # # => { # :email=>["is missing", "is in invalid format"], # :name=>["is missing"], # :tos=>["is missing"], # :age=>["is missing"], # :address=>["is missing"] # } attr_reader :errors # Initialize the params and freeze them. # # @param env [Hash] a Rack env or an hash of params. # # @return [Params] # # @since 0.1.0 # @api private def initialize(env:, contract: nil) @env = env @raw = _extract_params # Fall back to the default contract here, rather than in the `._contract` method itself. # This allows `._contract` to return nil when there is no user-defined contract, which is # important for the backwards compatibility behavior in `Validatable::ClassMethods#params`. contract ||= self.class._contract || DefaultContract validation = contract.call(raw) @params = validation.to_h @errors = Errors.new(validation.errors.to_h) 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 # path semantics as `Hash#dig`. # # @param keys [Array] the key # # @return [Object,NilClass] return the associated value, if found # # @example # require "hanami/controller" # # module Deliveries # class Create < Hanami::Action # def handle(req, *) # req.params.get(:customer_name) # => "Luca" # req.params.get(:uknown) # => nil # # req.params.get(:address, :city) # => "Rome" # req.params.get(:address, :unknown) # => nil # # req.params.get(:tags, 0) # => "foo" # req.params.get(:tags, 1) # => "bar" # req.params.get(:tags, 999) # => nil # # req.params.get(nil) # => nil # end # 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 flat collection of full error messages # # @return [Array] # # @since 0.7.0 # # @example # params.error_messages # # => [ # "Email is missing", # "Email is in invalid format", # "Name is missing", # "Tos is missing", # "Age is missing", # "Address is missing" # ] def error_messages(error_set = errors) error_set.each_with_object([]) do |(key, messages), result| k = Utils::String.titleize(key) msgs = if messages.is_a?(::Hash) error_messages(messages) else messages.map { |message| "#{k} #{message}" } end result.concat(msgs) end end # Returns true if no validation errors are found, # false otherwise. # # @return [TrueClass, FalseClass] # # @since 0.7.0 # # @example # params.valid? # => true def valid? errors.empty? end # 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 # Serialize validated params to Hash # # @return [::Hash] # # @since 0.3.0 def to_h @params end alias_method :to_hash, :to_h # Pattern-matching support # # @return [::Hash] # # @since 2.0.2 def deconstruct_keys(*) to_hash 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 = {}) env.fetch(ROUTER_PARAMS) do if session = fallback.delete(Action::RACK_SESSION) fallback[Action::RACK_SESSION] = Utils::Hash.deep_symbolize(session) end fallback end end end end end