diff --git a/CHANGELOG.md b/CHANGELOG.md index d41167b..5a2bfc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ # Changelog All notable changes to this project made by Monade Team are documented in this file. For info refer to team@monade.io +## [2.0.0-BETA] - 2023-09-27 +### Added +- `transformer` option to param, that allows to transform the value received from a parameter before assigning it to the sanitized hash. +- `default` now works also with nested structures. + +### Changed +- [BREAKING] All methods can now be called without ! (param, params, default, etc...) +- [BREAKING] `array` and `group` are not identical anymore. `array` will accepts an array of values, while `group` will accept a single hash. +- [BREAKING] internal, removed to_defaults, replacing it with apply_defaults! + +### Fixed +- Missing params in nested structures are now reported correctly + + ## [1.0.0] - 2022-05-28 ### Added - First release diff --git a/README.md b/README.md index f34c0cc..3a56471 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,6 @@ end ## TODOs * Params type checking and regexp-based validations -* Value transformers About Monade ---------------- diff --git a/lib/paramoid.rb b/lib/paramoid.rb index 5ffaaa4..9e515ab 100644 --- a/lib/paramoid.rb +++ b/lib/paramoid.rb @@ -6,7 +6,6 @@ module Paramoid autoload :Object autoload :List autoload :Base - # autoload :Controller end require 'paramoid/controller' diff --git a/lib/paramoid/base.rb b/lib/paramoid/base.rb index bfb3d6b..fc16ddb 100644 --- a/lib/paramoid/base.rb +++ b/lib/paramoid/base.rb @@ -3,8 +3,8 @@ class Base # @param [ActionController::Parameters] params def sanitize(params) params = params.permit(*permitted_params) - scalar_params.transform_params!(params) - params = default_params.merge(params) + context.transform_params!(params) + context.apply_defaults!(params) ensure_required_params!(params) end @@ -13,39 +13,53 @@ def sanitize(params) # @param [Symbol] name # @param [Symbol] as # @param [Lambda | NilClass] transformer - def group!(name, as: nil, transformer: nil) - key = as || name - data = Object.new(name, key, nested: List.new, transformer: transformer) - context << data - return unless block_given? + def group(name, as: nil, transformer: nil, &block) + _nest_rule(name, as: as, transformer: transformer, nesting_type: :object, &block) + end - old_context = context - @context = data - yield - @context = old_context + def list(name, as: nil, transformer: nil, &block) + _nest_rule(name, as: as, transformer: transformer, nesting_type: :list, &block) end - alias list! group! - alias array! group! + alias array list # @param [Array] names - def params!(*names, required: false) - names.each { |name| param! name, required: required } + def params(*names, required: false) + names.each { |name| param name, required: required } end - def param!(name, as: nil, transformer: nil, default: nil, required: false) + def param(name, as: nil, transformer: nil, default: nil, required: false) key = as || name data = Object.new(name, key, nested: nil, default: default, transformer: transformer, required: required) context << data end - def default!(name, value) + def default(name, value) data = Object.new(name, name, nested: nil, default: value) context << data end + alias param! param + alias params! params + alias group! group + alias default! default + alias array! array + alias list! list + private + def _nest_rule(name, nesting_type:, as: nil, transformer: nil) + key = as || name + data = Object.new(name, key, nested: List.new, nesting_type: nesting_type, transformer: transformer) + context << data + return unless block_given? + + old_context = context + @context = data + yield + @context = old_context + end + def context @context ||= scalar_params end diff --git a/lib/paramoid/list.rb b/lib/paramoid/list.rb index d631a78..8313899 100644 --- a/lib/paramoid/list.rb +++ b/lib/paramoid/list.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Paramoid class List < Array def to_params @@ -12,13 +14,20 @@ def transform_params!(params) end end + def apply_defaults!(params) + each do |params_data| + params = params_data.apply_defaults! params + end + params + end + def to_defaults inject({}) { |a, b| a.merge!(b.to_defaults) } end - def ensure_required_params!(params) + def ensure_required_params!(params, path: []) each do |params_data| - params_data.ensure_required_params! params + params_data.ensure_required_params! params, path: path end end end diff --git a/lib/paramoid/object.rb b/lib/paramoid/object.rb index 3939004..b756007 100644 --- a/lib/paramoid/object.rb +++ b/lib/paramoid/object.rb @@ -1,4 +1,5 @@ module Paramoid + # rubocop:disable Metrics/ClassLength class Object # @return [Symbol] the parameter name attr_reader :name @@ -19,19 +20,18 @@ class Object # @param [Object] default # @param [Lambda, NilClass] transformer # @param [TrueClass, FalseClass] required - def initialize(name, alias_name, nested: nil, transformer: nil, default: nil, required: false) + def initialize(name, alias_name, nested: nil, nesting_type: nil, transformer: nil, default: nil, required: false) @name = name @alias = alias_name @nested = nested @default = default @transformer = transformer @required = required + @nesting_type = nesting_type end # @param [Array, Hash] params def transform_params!(params) - return if @alias == @name - if params.is_a?(Array) params.each { |param| transform_params!(param) } return @@ -39,6 +39,8 @@ def transform_params!(params) return unless params.key?(@name) + params[@name] = @transformer.call(params[@name]) if @transformer.respond_to?(:call) + @nested.transform_params!(params[@name]) if nested? params[@alias] = params.delete(@name) unless @alias == @name end @@ -59,21 +61,63 @@ def to_required_params end end - def ensure_required_params!(params) - if @required - raise ActionController::ParameterMissing, output_key unless params&.key?(output_key) - raise ActionController::ParameterMissing, output_key if params[output_key].nil? + def ensure_required_params!(params, path: []) + current_path = [*path, @name] + + _raise_on_missing_parameter!(params, output_key, current_path) if @required + + if nested? + if params.is_a?(Array) + params.each { |param| @nested.ensure_required_params!(param[output_key], path: current_path) } + else + @nested.ensure_required_params!(params ? params[output_key] : nil, path: current_path) + end end - @nested.ensure_required_params!(params[output_key]) if nested? + params + end + + def apply_defaults!(params) + return apply_nested_defaults!(params) if nested? + + return params unless @default + + return params if @nesting_type == :list && !params + + params ||= {} + apply_scalar_defaults!(params) params end + def apply_scalar_defaults!(params) + if params.is_a?(Array) + params.map! { |param| apply_scalar_defaults!(param) } + else + params[output_key] ||= @default + end + params + end + + def apply_nested_defaults!(params) + return params unless params + + params ||= @nesting_type == :list ? [] : {} + if params.is_a?(Array) + params.map! { |param| @nested.apply_defaults!(param[output_key]) } + else + return params unless params[output_key] + + result = @nested.apply_defaults!(params[output_key]) + params[output_key] = result if result + end + params + end + def to_defaults if nested? nested_defaults = @nested.to_defaults - (nested_defaults.present? ? { output_key => @nested.to_defaults } : {}).with_indifferent_access + (nested_defaults.present? ? { output_key => nested_defaults } : {}).with_indifferent_access else (@default ? { output_key => @default } : {}).with_indifferent_access end @@ -90,5 +134,17 @@ def <<(value) def nested? !@nested.nil? end + + private + + def _raise_on_missing_parameter!(params, key, current_path) + return if _param_exist?(params, key) + + raise ActionController::ParameterMissing, current_path.join('.') + end + + def _param_exist?(params, key) + params&.key?(key) && !params[key].nil? + end end end diff --git a/spec/paramoid/base_spec.rb b/spec/paramoid/base_spec.rb index c5f0bec..6980405 100644 --- a/spec/paramoid/base_spec.rb +++ b/spec/paramoid/base_spec.rb @@ -1,5 +1,6 @@ require 'spec_helper' +# rubocop:disable Metrics/BlockLength describe PersonParamsSanitizer, type: :controller do let(:params) do ActionController::Parameters.new(params_hash) @@ -15,8 +16,7 @@ current_user_id: 2, first_name: 'John', last_name: 'Doe', - # TODO: Implement transformers - # email: 'Hello@MyCustomMAIL.COM', + email: 'Hello@MyCustomMAIL.COM', role: 'some_role', unwanted: 'hello', an_object_filtered: { name: 'value' }, @@ -38,13 +38,12 @@ end it 'keeps only allowed params' do - expect(sanitized).to eq( + expect(sanitized.to_unsafe_h).to eq( { 'current_user_id' => 2, 'first_name' => 'John', 'last_name' => 'Doe', - # TODO: Implement transformers - # 'email' => 'hello@mycustommail.com', + 'email' => 'hello@mycustommail.com', 'some_default' => 1, 'an_array_unfiltered' => [1, 2, 3, 4, 5] } @@ -56,7 +55,10 @@ {} end it 'raises an error' do - expect { sanitized }.to raise_error(ActionController::ParameterMissing) + expect { sanitized }.to raise_error( + ActionController::ParameterMissing, + 'param is missing or the value is empty: current_user_id' + ) end end @@ -78,3 +80,4 @@ end end end +# rubocop:enable Metrics/BlockLength diff --git a/spec/paramoid/complex_spec.rb b/spec/paramoid/complex_spec.rb new file mode 100644 index 0000000..8e6554e --- /dev/null +++ b/spec/paramoid/complex_spec.rb @@ -0,0 +1,123 @@ +require 'spec_helper' + +# rubocop:disable Metrics/BlockLength +describe ComplexParamsSanitizer, type: :controller do + let(:params) do + ActionController::Parameters.new(params_hash) + end + + subject { described_class.new(user) } + let(:sanitized) { subject.sanitize(params) } + + describe 'when params are valid' do + let(:user) { double(admin?: false) } + let(:params_hash) do + { + + unwanted: 1, + name: 'some_name', + buyer: { + payment_method: { + id: 1 + } + } + } + end + let(:params) do + ActionController::Parameters.new(params_hash) + end + + it 'filters out unwanted params' do + expect(sanitized).not_to have_key(:unwanted) + end + + it 'has the default values of existing objects' do + expect(sanitized.to_unsafe_h).to eq( + { + 'buyer' => { 'payment_method' => { 'uuid' => 1 } }, + 'total' => 0, + 'name' => 'some_name' + } + ) + end + + context 'when a nested object has at least a value' do + let(:params_hash) do + { + + unwanted: 1, + name: 'some_name', + buyer: { + payment_method: { + id: 1 + } + }, + person: {}, + items: [{}, {}] + } + end + it 'has the default value for those' do + expect(sanitized.to_unsafe_h).to eq( + { + 'buyer' => { 'payment_method' => { 'uuid' => 1 } }, + 'total' => 0, + 'name' => 'some_name', + 'items' => [{ 'price' => 0 }, { 'price' => 0 }], + 'person_attributes' => { 'role' => :user } + } + ) + end + end + end + + describe 'when params all parameters are set' do + let(:user) { double(admin?: true) } + let(:params_hash) do + { + name: 'some_name', + unwanted: 1, + buyer: { + payment_method: { + id: 1 + } + }, + items: [{ id: 5, name: 'some_name', discount: 10 }], + total: 100, + person: { id: 1, full_name: 'some_name' } + + } + end + let(:params) do + ActionController::Parameters.new(params_hash) + end + + it 'has the default values correctly nested' do + expect(sanitized.to_unsafe_h).to eq( + { + 'buyer' => { 'payment_method' => { 'uuid' => 1 } }, + 'items' => [{ 'price' => 0, 'name' => 'some_name', 'discount' => 0.1, 'id' => 5 }], + 'total' => 100, + 'name' => 'some_name', + 'person_attributes' => { 'role' => :admin, 'id' => 1, 'full_name' => 'some_name' } + } + ) + end + end + + describe 'when required params are missing' do + let(:user) { double(admin?: false) } + let(:params_hash) do + { + name: 'some_name' + } + end + + it 'raises an error' do + expect { sanitized }.to raise_error( + ActionController::ParameterMissing, + 'param is missing or the value is empty: buyer.payment_method.id' + ) + end + end +end +# rubocop:enable Metrics/BlockLength diff --git a/spec/paramoid/controller_spec.rb b/spec/paramoid/controller_spec.rb index b84998c..9106c5a 100644 --- a/spec/paramoid/controller_spec.rb +++ b/spec/paramoid/controller_spec.rb @@ -6,8 +6,7 @@ current_user_id: 2, first_name: 'John', last_name: 'Doe', - # TODO: Implement transformers - # email: 'Hello@MyCustomMAIL.COM', + email: 'Hello@MyCustomMAIL.COM', role: 'some_role', unwanted: 'hello', an_object_filtered: { name: 'value' }, diff --git a/spec/paramoid/object_spec.rb b/spec/paramoid/object_spec.rb index 787e923..effc08f 100644 --- a/spec/paramoid/object_spec.rb +++ b/spec/paramoid/object_spec.rb @@ -1,5 +1,6 @@ require 'spec_helper' +# rubocop:disable Metrics/BlockLength describe Paramoid::Object do let(:name) { :some_param } let(:alias_name) { :some_param } @@ -105,10 +106,10 @@ expect do subject.ensure_required_params!(params) end.to raise_error(ActionController::ParameterMissing, - 'param is missing or the value is empty: nested') + 'param is missing or the value is empty: some_param.nested') end - context 'and it\'s required' do + context 'and some_param is required' do let(:required) { true } it 'raises an error on the parent param' do @@ -118,6 +119,18 @@ 'param is missing or the value is empty: some_param') end end + + context 'when params is nil' do + let(:params) { nil } + + it 'skips the check' do + expect do + subject.ensure_required_params!(params) + end.to raise_error(ActionController::ParameterMissing, + 'param is missing or the value is empty: some_param.nested') + end + end end end end +# rubocop:enable Metrics/BlockLength diff --git a/spec/support/params.rb b/spec/support/params.rb index 759a3a0..974bf3a 100644 --- a/spec/support/params.rb +++ b/spec/support/params.rb @@ -21,3 +21,33 @@ def initialize(user = nil) end end end + +class ComplexParamsSanitizer < Paramoid::Base + def initialize(user = nil) + group :person, as: :person_attributes do + params :id, :full_name + + if user.admin? + param :role, default: :admin + else + default :role, :user + end + end + + group :buyer do + group :payment_method do + param :id, required: true, as: :uuid + end + end + + array :items do + params :id, :name + + default :price, 0 + param :discount, transformer: ->(data) { data&.to_f / 100 } if user.admin? + end + + default :total, 0 + param :name, required: true + end +end