diff --git a/lib/hanami/action.rb b/lib/hanami/action.rb index 4e1015cb..4708c2f7 100644 --- a/lib/hanami/action.rb +++ b/lib/hanami/action.rb @@ -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) diff --git a/lib/hanami/action/params.rb b/lib/hanami/action/params.rb index 2e1dfe83..5e82a4d3 100644 --- a/lib/hanami/action/params.rb +++ b/lib/hanami/action/params.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "hanami/validations/form" - module Hanami class Action # A set of params requested by the client @@ -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 # @@ -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. @@ -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 # diff --git a/lib/hanami/action/validatable.rb b/lib/hanami/action/validatable.rb index 6eaceb64..ffd4cda7 100644 --- a/lib/hanami/action/validatable.rb +++ b/lib/hanami/action/validatable.rb @@ -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 @@ -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 @@ -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 diff --git a/spec/isolation/without_hanami_validations_spec.rb b/spec/isolation/without_hanami_validations_spec.rb index d0568d3f..d88e3ec9 100644 --- a/spec/isolation/without_hanami_validations_spec.rb +++ b/spec/isolation/without_hanami_validations_spec.rb @@ -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 diff --git a/spec/support/fixtures.rb b/spec/support/fixtures.rb index ac803e03..083dc040 100644 --- a/spec/support/fixtures.rb +++ b/spec/support/fixtures.rb @@ -1902,3 +1902,68 @@ def call(env) end end end + +class ContractAction < Hanami::Action + contract do + params do + required(:birth_date).filled(:date) + required(:book).schema do + required(:title).filled(:str?) + end + end + + rule(:birth_date) do + key.failure("you must be 18 years or older") if value < Date.today << (12 * 18) + end + end + + def handle(request, response) + if request.params.valid? + response.status = 201 + response.body = JSON.generate( + new_name: request.params[:book][:title].upcase + ) + else + response.body = {errors: request.params.errors.to_h} + response.status = 302 + end + end +end + +class ExternalContractParams < Hanami::Action::Params + contract do + params do + required(:birth_date).filled(:date) + required(:book).schema do + required(:title).filled(:str?) + end + end + + rule(:birth_date) do + key.failure("you must be 18 years or older") if value < Date.today << (12 * 18) + end + end +end + +class ExternalContractAction < ContractAction + contract ExternalContractParams +end + +class WhitelistedUploadDslContractAction < Hanami::Action + contract do + params do + required(:id).maybe(:integer) + required(:upload).filled + end + end + + def handle(req, res) + res.body = req.params.to_h.inspect + end +end + +class RawContractAction < Hanami::Action + def handle(req, res) + res.body = req.params.to_h.inspect + end +end diff --git a/spec/unit/hanami/action/contract_spec.rb b/spec/unit/hanami/action/contract_spec.rb new file mode 100644 index 00000000..692903e5 --- /dev/null +++ b/spec/unit/hanami/action/contract_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rack" + +RSpec.describe "Contract" do + describe "defined inline in action" do + let(:action) { ContractAction.new } + + context "when it has errors" do + it "returns them" do + response = action.call("birth_date" => "2000-01-01") + + expect(response.status).to eq 302 + expect(response.body).to eq ["{:errors=>{:book=>[\"is missing\"], :birth_date=>[\"you must be 18 years or older\"]}}"] + end + end + + context "when it is valid" do + it "works" do + response = action.call("birth_date" => Date.today - (365 * 15), "book" => {"title" => "Hanami"}) + + expect(response.status).to eq 201 + expect(response.body).to eq ["{\"new_name\":\"HANAMI\"}"] + end + end + end + + describe "provided by a standlone params class using a contract" do + let(:action) { ExternalContractAction.new } + + context "when it has errors" do + it "returns them" do + response = action.call("birth_date" => "2000-01-01") + + expect(response.status).to eq 302 + expect(response.body).to eq ["{:errors=>{:book=>[\"is missing\"], :birth_date=>[\"you must be 18 years or older\"]}}"] + end + end + + context "when it is valid" do + it "works" do + response = action.call("birth_date" => Date.today - (365 * 15), "book" => {"title" => "Hanami"}) + + expect(response.status).to eq 201 + expect(response.body).to eq ["{\"new_name\":\"HANAMI\"}"] + end + end + end + + describe "standalone class" do + it "validates the input" do + params_class = Class.new(Hanami::Action::Params) { + contract do + params do + required(:start_date).value(:date) + end + + rule(:start_date) do + key.failure("must be in the future") if value <= Date.today + end + end + } + + params = params_class.new(start_date: "2000-01-01") + + expect(params.errors.to_h).to eq(start_date: ["must be in the future"]) + end + end + + describe "#raw" do + context "without a contract" do + let(:action) { RawContractAction.new } + + it "raw gets all params" do + File.open("spec/support/fixtures/multipart-upload.png", "rb") do |upload| + response = action.call("id" => "1", "unknown" => "2", "upload" => upload) + + expect(response[:params][:id]).to eq("1") + expect(response[:params][:unknown]).to eq("2") + expect(FileUtils.cmp(response[:params][:upload], upload)).to be(true) + + expect(response[:params].raw.fetch("id")).to eq("1") + expect(response[:params].raw.fetch("unknown")).to eq("2") + expect(response[:params].raw.fetch("upload")).to eq(upload) + end + end + end + + context "with a contract" do + let(:action) { WhitelistedUploadDslContractAction.new } + + it "raw gets all params" do + Tempfile.create("multipart-upload") do |upload| + response = action.call("id" => "1", "unknown" => "2", "upload" => upload, "_csrf_token" => "3") + + expect(response[:params][:id]).to eq(1) + expect(response[:params][:unknown]).to be(nil) + expect(response[:params][:upload]).to eq(upload) + + expect(response[:params].raw.fetch("id")).to eq("1") + expect(response[:params].raw.fetch("unknown")).to eq("2") + expect(response[:params].raw.fetch("upload")).to eq(upload) + end + end + end + end +end diff --git a/spec/unit/hanami/action/params_spec.rb b/spec/unit/hanami/action/params_spec.rb index da356999..424899c8 100644 --- a/spec/unit/hanami/action/params_spec.rb +++ b/spec/unit/hanami/action/params_spec.rb @@ -3,16 +3,6 @@ require "rack" RSpec.describe Hanami::Action::Params do - xit "is frozen" - - # This is temporary suspended. - # We need to get the dependency Hanami::Validations, more stable before to enable this back. - # - # it 'is frozen' do - # params = Hanami::Action::Params.new({id: '23'}) - # params.must_be :frozen? - # end - describe "#raw" do let(:params) { Class.new(Hanami::Action::Params) } @@ -495,7 +485,7 @@ expect(params).to_not be_valid end - it "appens message to already existing messages" do + it "appends message to already existing messages" do params = klass.new(book: {}) params.errors.add(:book, :code, "is invalid")