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

Contracts in actions #1

Closed
wants to merge 6 commits into from
Closed
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
22 changes: 19 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 @@ -109,6 +109,7 @@ def self.inherited(subclass)
if instance_variable_defined?(:@params_class)
subclass.instance_variable_set(:@params_class, @params_class)
end

end

# Returns the class which defines the params
Expand All @@ -134,10 +135,25 @@ def self.params_class
# @api public
# @since 2.0.0
def self.params(_klass = nil)

raise NoMethodError,
"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 +321,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
139 changes: 139 additions & 0 deletions lib/hanami/action/contract.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# 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
# 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 output
__getobj__.to_h
end

# @since 2.0.0
# @api private
def messages
__getobj__.errors.to_h
end
end

# @attr_reader env [Hash] the Rack env
#
# @since 2.2.0
# @api private
attr_reader :env

# 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)
validations(&blk || -> {})
end

# @since 2.2.0
# @api private
class << self
def validations(&blk)
@_validator = Dry::Validation::Contract.build(&blk)
end

attr_reader :_validator
end

# Initialize the contract and freeze it.
#
# @param env [Hash] a Rack env or an hash of params.
#
# @return [Hash]
#
# @since 2.2.0
# @api public
def initialize(env)
@env = env
@input = Hanami::Action::ParamsExtraction.new(env).call
validation = validate
@params = validation.to_h
@errors = Hanami::Action::Params::Errors.new(validation.messages)
freeze
end

attr_reader :errors

# Returns true if no validation errors are found,
# false otherwise.
#
# @return [TrueClass, FalseClass]
#
# @since 2.2.0
#
def valid?
errors.empty?
end

# Serialize validated params to Hash
#
# @return [::Hash]
#
# @since 2.2.0
def to_h
validate.output
end
alias_method :to_hash, :to_h

attr_reader :result

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

private

# @since 2.2.0
def validate
Result.new(
self.class._validator.call(@input)
)
end
end
end
end
47 changes: 47 additions & 0 deletions lib/hanami/action/params_extraction.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

require "rack/request"

module Hanami
class Action
# since 2.2.0
# @api private
class ParamsExtraction
def initialize(env)
@env = env
end

def call
_extract_params
end

private

attr_reader :env

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

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
14 changes: 14 additions & 0 deletions lib/hanami/action/validatable.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative "params"
require_relative "contract"

module Hanami
class Action
Expand All @@ -17,6 +18,12 @@ module Validatable
# @since 0.3.0
PARAMS_CLASS_NAME = "Params"

# Defines the contract base class
#
# @api private
# @since 2.2.0
CONTRACT_CLASS_NAME = "Contract"

# @api private
# @since 0.1.0
def self.included(base)
Expand Down Expand Up @@ -102,7 +109,14 @@ def params(klass = nil, &blk)
klass = const_set(PARAMS_CLASS_NAME, Class.new(Params))
klass.class_eval { params(&blk) }
end
@params_class = klass
end

# @since 2.2.0
# @api public
def contract(&blk)
klass = const_set(CONTRACT_CLASS_NAME, Class.new(Contract))
klass.class_eval { contract(&blk) }
@params_class = klass
end
end
Expand Down
22 changes: 22 additions & 0 deletions spec/isolation/without_hanami_validations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
expect(defined?(Hanami::Action::Params)).to be(nil)
end

it "doesn't load Hanami::Action::Contract" do
expect(defined?(Hanami::Action::Contract)).to be(nil)
end

it "doesn't have params DSL" do
expect do
Class.new(Hanami::Action) do
Expand All @@ -28,6 +32,24 @@
)
end

it "doesn't have contract DSL" do
expect do
Class.new(Hanami::Action) do
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
end
end.to raise_error(
NoMethodError,
/To use `contract`, please add 'hanami\/validations' gem to your Gemfile/
)
end

it "has params that don't respond to .valid?" do
action = Class.new(Hanami::Action) do
def handle(req, res)
Expand Down
59 changes: 59 additions & 0 deletions spec/support/fixtures.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1902,3 +1902,62 @@ 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 BaseContract < Hanami::Action::Contract
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
end

AddressSchema = Dry::Schema.Params do
required(:country).value(:string)
required(:zipcode).value(:string)
required(:street).value(:string)
end

ContactSchema = Dry::Schema.Params do
required(:email).value(:string)
required(:mobile).value(:string)
end

class OutsideSchemasContract < Hanami::Action::Contract
contract do
params(AddressSchema, ContactSchema) do
required(:name).value(:string)
required(:age).value(:integer)
end
end
end
Loading