diff --git a/CHANGELOG.md b/CHANGELOG.md index 38def317..6219703f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog ++ **1.5.0** - _05/23/2018_ + - Add the capability to validate params based on documented parameters on endpoints. Invoking + `brainstem_validate_params!` raises an error if unknown parameters are encountered. + + **1.4.1** - _05/09/2018_ - Add the capability to specify an alternate base application / engine the routes are derived from. This capability is specific to documemtation generation. diff --git a/lib/brainstem/concerns/controller_dsl.rb b/lib/brainstem/concerns/controller_dsl.rb index a2948c5f..fefa56ad 100644 --- a/lib/brainstem/concerns/controller_dsl.rb +++ b/lib/brainstem/concerns/controller_dsl.rb @@ -1,4 +1,5 @@ require 'brainstem/concerns/inheritable_configuration' +require 'brainstem/params_validator' require 'active_support/core_ext/object/with_options' module Brainstem @@ -370,7 +371,8 @@ def valid_params_tree(requested_context = action_name.to_sym) # Lists all valid parameters for the current action. Falls back to the # valid parameters for the default context. # - # @params [Symbol] requested_context the context which to look up. + # @params [String, Symbol] (Optional) requested_context the context which to look up. + # @params [String, Symbol] (Optional) root_param_name the param name of the model being changed. # # @return [Hash{String => String, Hash] a hash of pairs of param names and # descriptions or sub-hashes. @@ -380,6 +382,21 @@ def brainstem_valid_params(requested_context = action_name.to_sym, root_param_na end alias_method :brainstem_valid_params_for, :brainstem_valid_params + # + # Ensures that the parameters passed through to the action are valid. + # + # It raises Brainstem::ValidatorError.new(message, unknown_params, malformed_params) error, + # when params are missing or unknown params are encountered + # + # @params [String, Symbol] (Optional) requested_context the context which to look up. + # @params [String, Symbol] (Optional) root_param_name the param name of the model being changed. + # + def brainstem_validate_params!(requested_context = action_name.to_sym, root_param_name = brainstem_model_name) + input_params = params.with_indifferent_access[brainstem_model_name] + brainstem_params_config = brainstem_valid_params(requested_context, root_param_name) + + Brainstem::ParamsValidator.validate!(requested_context, input_params, brainstem_params_config) + end # # Lists all incoming param keys that will be rewritten to use a different diff --git a/lib/brainstem/malformed_params.rb b/lib/brainstem/malformed_params.rb new file mode 100644 index 00000000..ce05e14c --- /dev/null +++ b/lib/brainstem/malformed_params.rb @@ -0,0 +1,10 @@ +module Brainstem + class MalformedParams < StandardError + attr_reader :malformed_params + + def initialize(message = "Malformed Params sighted", malformed_params = []) + @malformed_params = malformed_params + super(message) + end + end +end diff --git a/lib/brainstem/params_validator.rb b/lib/brainstem/params_validator.rb new file mode 100644 index 00000000..956c8850 --- /dev/null +++ b/lib/brainstem/params_validator.rb @@ -0,0 +1,92 @@ +require 'brainstem/unknown_params' +require 'brainstem/malformed_params' +require 'brainstem/validation_error' + +module Brainstem + class ParamsValidator + attr_reader :malformed_params, :unknown_params + + def self.validate!(action_name, input_params, valid_params_config) + new(action_name, input_params, valid_params_config).validate! + end + + def initialize(action_name, input_params, valid_params_config) + @valid_params_config = valid_params_config + @input_params = sanitize_input_params!(input_params) + @action_name = action_name.to_s + + @unknown_params = [] + @malformed_params = [] + end + + def validate! + @input_params.each do |param_key, param_value| + param_data = @valid_params_config[param_key] + + if param_data.blank? + @unknown_params << param_key + next + end + + param_config = param_data[:_config] + nested_valid_params = param_data.except(:_config) + + if param_config[:only].present? && !param_config[:only].map(&:to_s).include?(@action_name) + @unknown_params << param_key + elsif param_config[:recursive].to_s == 'true' + validate_nested_params(param_key, param_config, param_value, @valid_params_config) + elsif parent_param?(param_data) + validate_nested_params(param_key, param_config, param_value, nested_valid_params) + end + end + + raise_when_invalid? ? unknown_params_error! : true + end + + private + + def raise_when_invalid? + @malformed_params.present? || @unknown_params.present? + end + + def parent_param?(param_data) + param_data.except(:_config).keys.present? + end + + def validate_nested_params(param_key, param_config, value, valid_nested_params) + return value if value.nil? + + param_type = param_config[:type] + if param_type == 'hash' + validate_nested_param(param_key, param_type, value, valid_nested_params) + elsif param_type == 'array' && !value.is_a?(Array) + @malformed_params << param_key + else + value.each { |value| validate_nested_param(param_key, param_type, value, valid_nested_params) } + end + end + + def validate_nested_param(parent_param_key, parent_param_type, value, valid_nested_params) + begin + self.class.validate!(@action_name, value, valid_nested_params) + rescue Brainstem::ValidationError => e + @unknown_params << { parent_param_key => e.unknown_params } + @malformed_params << { parent_param_key => e.malformed_params } + end + end + + def sanitize_input_params!(input_params) + malformed_params_error! unless input_params.is_a?(Hash) && input_params.present? + + input_params + end + + def malformed_params_error! + raise ::Brainstem::ValidationError.new("Input params are malformed") + end + + def unknown_params_error! + raise ::Brainstem::ValidationError.new("Invalid params encountered", @unknown_params, @malformed_params) + end + end +end diff --git a/lib/brainstem/unknown_params.rb b/lib/brainstem/unknown_params.rb new file mode 100644 index 00000000..132c5fe0 --- /dev/null +++ b/lib/brainstem/unknown_params.rb @@ -0,0 +1,10 @@ +module Brainstem + class UnknownParams < StandardError + attr_reader :unknown_params + + def initialize(message = "Unidentified Params sighted", unknown_params = []) + @unknown_params = unknown_params + super(message) + end + end +end diff --git a/lib/brainstem/validation_error.rb b/lib/brainstem/validation_error.rb new file mode 100644 index 00000000..ce6a0f9d --- /dev/null +++ b/lib/brainstem/validation_error.rb @@ -0,0 +1,12 @@ +module Brainstem + class ValidationError < StandardError + attr_reader :unknown_params, :malformed_params + + def initialize(message = "Invalid params sighted", unknown_params = [], malformed_params = []) + @unknown_params = unknown_params + @malformed_params = malformed_params + + super(message) + end + end +end diff --git a/spec/brainstem/concerns/controller_dsl_spec.rb b/spec/brainstem/concerns/controller_dsl_spec.rb index 4e54f11b..fa1b2935 100644 --- a/spec/brainstem/concerns/controller_dsl_spec.rb +++ b/spec/brainstem/concerns/controller_dsl_spec.rb @@ -983,6 +983,76 @@ module Concerns end end + describe "#brainstem_validate_params!" do + let(:brainstem_model_name) { "widget" } + let(:input_params) { { widget: { sprocket_parent_id: 5, sprocket_name: 'gears' } } } + + before do + stub(subject).brainstem_model_name { brainstem_model_name } + stub.any_instance_of(subject).brainstem_model_name { brainstem_model_name } + stub.any_instance_of(subject).params { input_params } + + subject.brainstem_params do + actions :update do + model_params(brainstem_model_name) do |params| + params.valid :sprocket_parent_id, :long, + info: "sprockets[sprocket_parent_id] is not required" + + params.valid :sprocket_name, :string, + info: "sprockets[sprocket_name] is required", + required: true + end + end + end + end + + it "returns true if params are OK" do + expect(subject.new.brainstem_validate_params!(:update, brainstem_model_name)).to be_truthy + end + + context "when parameters are in an invalid format" do + context "with an empty hash" do + let(:input_params) { {} } + + it "returns false and says params are missing" do + expect { + subject.new.brainstem_validate_params!(:update, brainstem_model_name) + }.to raise_error(Brainstem::ValidationError) + end + end + + context "with a non-hash object" do + let(:input_params) { { widget: [{ foo: "bar" }] } } + + it "returns false and says params are missing" do + expect { + subject.new.brainstem_validate_params!(:update, brainstem_model_name) + }.to raise_error(Brainstem::ValidationError) + end + end + + context "with nil" do + let(:input_params) { { widget: nil } } + + it "returns false and says params are missing" do + expect { + subject.new.brainstem_validate_params!(:update, brainstem_model_name) + }.to raise_error(Brainstem::ValidationError) + end + end + end + + context "when there are errors due to unknown params" do + let(:input_params) { { widget: { my_cool_param: "something" } } } + + it "lists unknown params" do + expect { + subject.new.brainstem_validate_params!(:update, brainstem_model_name) + }.to raise_error(Brainstem::ValidationError) + end + end + end + describe "#transforms" do before do subject.brainstem_params do diff --git a/spec/brainstem/params_validator_spec.rb b/spec/brainstem/params_validator_spec.rb new file mode 100644 index 00000000..5c4c42fb --- /dev/null +++ b/spec/brainstem/params_validator_spec.rb @@ -0,0 +1,433 @@ +require 'spec_helper' +require 'brainstem/params_validator' + +describe Brainstem::ParamsValidator do + let(:valid_params_config) do + { + sprocket_parent_id: { :_config => { type: 'integer' } }, + sprocket_name: { :_config => { type: 'string' } } + } + end + let(:action_name) { :create } + + subject { described_class.new(action_name, input_params, valid_params_config) } + + context "when input params has valid keys" do + let(:input_params) { { sprocket_parent_id: 5, sprocket_name: 'gears' } } + + it "returns true" do + expect(subject.validate!).to be_truthy + end + + context "when recursive attribute is given" do + let(:valid_params_config) do + { + sprocket_parent_id: { :_config => { type: 'integer' } }, + sprocket_name: { :_config => { type: 'string' } }, + sub_widget: { :_config => { type: 'hash', recursive: true } }, + sub_widgets: { :_config => { type: 'array', item_type: 'hash', recursive: true } }, + } + end + let(:action_name) { :create } + + context "when recursive attribute is a hash" do + let(:input_params) do + { + sprocket_parent_id: 5, + sprocket_name: 'gears', + sub_widget: sub_widget_param + } + end + + context "when attribute is nil" do + let(:sub_widget_param) { nil } + + it "returns true" do + expect(subject.validate!).to be_truthy + end + end + + context "when attributes are specified" do + let(:sub_widget_param) do + { + sprocket_parent_id: 15, + sprocket_name: 'ten gears', + } + end + + it "returns true" do + expect(subject.validate!).to be_truthy + end + end + end + + context "when recursive attribute is an array" do + let(:input_params) do + { + sprocket_parent_id: 5, + sprocket_name: 'gears', + sub_widgets: sub_widgets_params + } + end + + context "when attribute is nil" do + let(:sub_widgets_params) { nil } + + it "returns true" do + expect(subject.validate!).to be_truthy + end + end + + context "when attribute is an empty array" do + let(:sub_widgets_params) { [] } + + it "returns true" do + expect(subject.validate!).to be_truthy + end + end + + context "when attributes in an array" do + let(:sub_widgets_params) { [ { sprocket_parent_id: 15, sprocket_name: 'ten gears' } ] } + + it "returns true" do + expect(subject.validate!).to be_truthy + end + end + + skip "(UNSUPPORTED) when attributes in an hash" do + let(:sub_widgets_params) { { 0 => { title: "child" } } } + + it "returns true" do + expect(subject.validate!).to be_truthy + end + end + end + end + + context "when multi nested attributes are valid" do + let(:valid_params_config) do + { + full_name: { :_config => { type: 'string' } }, + + permissions: { + :_config => { type: 'hash' }, + can_edit: { :_config => { type: 'boolean' } }, + }, + + skills: { + :_config => { type: 'array', item_type: 'hash' }, + name: { :_config => { type: 'string' } }, + category: { + :_config => { type: 'hash' }, + + name: { :_config => { type: 'string' } } + } + } + } + end + + let(:input_params) { + { + full_name: 'Buzz Killington', + permissions: { can_edit: true }, + skills: [ + { name: 'Ruby', category: { name: 'Programming' } }, + { name: 'Karate', category: { name: 'Self Defense' } } + ] + } + } + + it "returns true" do + expect(subject.validate!).to be_truthy + end + + context "when expected param is a hash" do + context "when value is nil" do + let(:input_params) { + { + permissions: nil, + } + } + + it "returns true" do + expect(subject.validate!).to be_truthy + end + end + end + + context "when expected param is an array" do + context "when value is nil" do + let(:input_params) { + { + skills: nil + } + } + + it "returns true" do + expect(subject.validate!).to be_truthy + end + end + + context "when value is an empty array" do + let(:input_params) { + { + skills: [] + } + } + + it "returns true" do + expect(subject.validate!).to be_truthy + end + end + end + end + end + + context "when input parameters are invalid" do + context "with an empty hash" do + let(:input_params) { {} } + + it "raises an missing params error" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + end + + context "with a non-hash object" do + let(:input_params) { [{ foo: "bar" }] } + + it "raises an missing params error" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + end + + context "with nil" do + let(:input_params) { nil } + + it "raises an missing params error" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + end + end + + context "when params are supplied to the wrong action" do + let(:valid_params_config) do + { + sprocket_parent_id: { :_config => { type: 'integer', only: [:update] } }, + sprocket_name: { :_config => { type: 'string' } } + } + end + let(:input_params) { { sprocket_parent_id: 5, sprocket_name: 'gears' } } + let(:action) { :create } + + it "throws an unknown params error" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + end + + context "when input params have unknown keys" do + let(:input_params) { { my_cool_param: "something" } } + + it "lists unknown params" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + + context "when recursive attribute has invalid / unknown properties" do + let(:valid_params_config) do + { + sprocket_parent_id: { :_config => { type: 'integer' } }, + sprocket_name: { :_config => { type: 'string' } }, + sub_widget: { :_config => { type: 'hash', recursive: true } }, + sub_widgets: { :_config => { type: 'array', item_type: 'hash', recursive: true } }, + } + end + + context "when recursive attribute is a hash" do + context "when recursive attribute has invalid keys" do + let(:input_params) do + { + sprocket_parent_id: 5, + sprocket_name: 'gears', + sub_widget: { + invalid_id: 15, + sprocket_name: 'ten gears', + } + } + end + + it "raises an error" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + end + + context "when attribute is an empty hash" do + let(:input_params) do + { + sprocket_parent_id: 5, + sprocket_name: 'gears', + sub_widget: {} + } + end + + it "raises an error" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + end + end + + context "when recursive attribute is an array" do + let(:input_params) do + { + sprocket_parent_id: 5, + sprocket_name: 'gears', + sub_widgets: [ + { invalid_id: 15, sprocket_name: 'ten gears' } + ] + } + end + + it "raises an error" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + end + end + + context "when multi nested attributes are invalid" do + let(:restrict_to_actions) { [] } + let(:valid_params_config) do + { + full_name: { :_config => { type: 'string' } }, + + permissions: { + :_config => { type: 'hash', only: restrict_to_actions }, + + can_edit: { :_config => { type: 'boolean' } }, + }, + + skills: { + :_config => { type: 'array', item_type: 'hash' }, + + name: { :_config => { type: 'string', only: restrict_to_actions } }, + category: { + :_config => { type: 'hash' }, + + name: { :_config => { type: 'string' } } + } + } + } + end + let(:input_params) { { sprocket_parent_id: 5, sprocket_name: 'gears' } } + + context "when params are supplied to the wrong action" do + let(:action) { :create } + let(:restrict_to_actions) { [:update] } + let(:input_params) { + { + full_name: 'Buzz Killington', + permissions: { can_edit: true }, + skills: [ + { name: 'Ruby', category: { name: 'Programming' } }, + { name: 'Kaarate', category: { name: 'Self Defense' } } + ] + } + } + + it "raises an error" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + end + + context "when unknown params are present" do + context "when present in a hash" do + let(:input_params) { + { + full_name: 'Buzz Killington', + permissions: { can_edit: true, invalid: 'blah' }, + skills: [ + { name: 'Ruby', category: { name: 'Programming' } }, + { name: 'Kaarate', category: { name: 'Self Defense' } } + ] + } + } + + it "raises an error" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + end + + context "when present in an array" do + let(:input_params) { + { + full_name: 'Buzz Killington', + permissions: { can_edit: true }, + skills: [ + { name: 'Ruby', category: { invalid: 'blah' } }, + { name: 'Kaarate', category: { name: 'Self Defense' } } + ] + } + } + + it "raises an error" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + end + end + + context "when params are malformed" do + context "when expected to be an array" do + context "when given an array with an empty hash" do + let(:input_params) { + { + skills: [ + { name: 'Ruby', category: { invalid: 'blah' } }, + {}, + ] + } + } + + it "raises an error" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + end + + context "when given a hash" do + let(:input_params) { + { + skills: {}, + } + } + + it "raises an error" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + end + end + + context "when expected to be a hash" do + context "when given an array" do + let(:input_params) { + { + permissions: [ { can_edit: true } ] + } + } + + it "raises an error" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + end + + context "when given an empty hash" do + let(:input_params) { + { + permissions: {}, + } + } + + it "raises an error" do + expect { subject.validate! }.to raise_error(Brainstem::ValidationError) + end + end + end + end + end + end +end diff --git a/spec/brainstem/presenter_collection_spec.rb b/spec/brainstem/presenter_collection_spec.rb index a1159c56..12e60078 100644 --- a/spec/brainstem/presenter_collection_spec.rb +++ b/spec/brainstem/presenter_collection_spec.rb @@ -1220,7 +1220,7 @@ class ArrayPresenter < Brainstem::Presenter describe "for! method" do it "raises if there is no presenter for the given class" do - expect{ Brainstem.presenter_collection("v1").for!(String) }.to raise_error(ArgumentError) + expect { Brainstem.presenter_collection("v1").for!(String) }.to raise_error(ArgumentError) end end @@ -1245,7 +1245,7 @@ class AnotherWorkspace < Workspace end it "raises if there is no presenter for the given class" do - expect{ Brainstem.presenter_collection("v1").brainstem_key_for!(String) }.to raise_error(ArgumentError) + expect { Brainstem.presenter_collection("v1").brainstem_key_for!(String) }.to raise_error(ArgumentError) end end end