pastore 0.0.4 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/docs/Params.md ADDED
@@ -0,0 +1,122 @@
1
+ # `Pastore::Params`
2
+
3
+ `Pastore::Params` is the module that provides the features for params validation in Rails controllers. It allows you to define the params with their data type (`string`, `number`, `boolean`, `date` and `object`), specify which params are mandatory and cannot be blank.
4
+
5
+ **Table of Contents**
6
+
7
+ - [`Pastore::Params`](#pastoreparams)
8
+ - [Setup](#setup)
9
+ - [Specifying params](#specifying-params)
10
+ - [Available param types](#available-param-types)
11
+ - [Avaliable options](#avaliable-options)
12
+
13
+ ## Setup
14
+
15
+ To start using `Pastore::Params` you just need to include `Pastore::Params` module in your controller. If you plan to use it in all your controllers, just add the following to your `ApplicationController` class:
16
+
17
+ ```ruby
18
+ # app/controllers/application_controller.rb
19
+
20
+ class ApplicationController < ActionController::API
21
+ include Pastore::Params
22
+
23
+ # Specify response status code to use for invalid params (default: unprocessable_entity)
24
+ invalid_params_status :bad_request
25
+
26
+ # Here you can customize the response to return on invalid params
27
+ on_invalid_params do
28
+ render json: { message: 'Invalid params' }
29
+ end
30
+
31
+ # ...
32
+ end
33
+ ```
34
+
35
+ ## Specifying params
36
+
37
+ Once you have configured `Pastore::Params` in your controller you can use the `param` method to define your params.
38
+
39
+ `param` method has the following signature:
40
+
41
+ ```ruby
42
+ param PARAM_NAME, **OPTIONS
43
+ ```
44
+
45
+ `PARAM_NAME` can be a `String` or a `Symbol`, while `OPTIONS` is a `Hash`.
46
+
47
+ Below you can find some examples of params definition using `param` method:
48
+
49
+ ```ruby
50
+ class UsersController < ApplicationController
51
+ # specify that :query param is a string, so that it will automatically be converted to string
52
+ param :query, type: :string
53
+ # specify that :page param is a number, which will be defaulted to 1 and will have 1 as lower limit
54
+ param :page, type: :number, default: 1, min: 1
55
+ # specify that :per_page param is a number, which will be defaulted to 15 and will enforce the value to be in a range between 1 and 200
56
+ param :per_page, type: :number, default: 15, clamp: 1..200
57
+ def index
58
+ # ... your code ...
59
+ end
60
+
61
+ # specify that :id param is a number and cannot be missing or blank
62
+ param :id, type: :number, allow_blank: false
63
+ def show
64
+ # ... your code ...
65
+ end
66
+
67
+ param :id, type: :number, allow_blank: false
68
+ # Sometimes you may want to specify a scope for the parameters, because you might have
69
+ # nested params, like `params[:user][:name]`
70
+ scope :user do
71
+ param :name, type: :string, allow_blank: false
72
+ # For string params you can set a format regexp validation, which will be automatically applied
73
+ param :email, type: :string, required: true, format: URI::MailTo::EMAIL_REGEXP,
74
+ modifier: ->(v) { v.strip.downcase }
75
+ param :birth_date, type: :date, required: true, max: DateTime.now
76
+ end
77
+ # You can also specify the scope inline
78
+ param :preferences, scope: :user, type: :object, default: {}, allow_blank: false
79
+ def update
80
+ # ... your code ...
81
+ end
82
+ end
83
+ ```
84
+
85
+ ### Available param types
86
+
87
+ `Pastore::Params` supports the following param types:
88
+
89
+ | Param type | Aliases | Description |
90
+ |------------|---------|-------------|
91
+ | `:string` | | Accepts string values or converts other value types to string. |
92
+ | `:number` | `integer`, `float` | Accepts integer values or tries to convert string to number. |
93
+ | `:boolean` | | Accepts boolean values or tries to convert string to boolean. |
94
+ | `:date` | | Accepts date values or tries to convert string or number (unix time) to date. |
95
+ | `:object` | | Accepts object (`Hash`) values or tries to convert JSON string to object. |
96
+ | `:any` | | Accepts any value. |
97
+
98
+ ### Avaliable options
99
+
100
+ There're several generic options that can be used with all param types, which are listed below:
101
+
102
+ | Option | Value type | Default | Description |
103
+ |--------|------------|---------|-------------|
104
+ | `:type` | `symbol`, `string`, `Class` | `:any` | Specifies the type of the parameter. |
105
+ | `:scope` | `symbol`, `string`, `symbol[]`, `string[]` | `nil` | Specifies the scope of the parameter, which is necessary for nested params definition like `params[:user][:email]`. |
106
+ | `:required` | `boolean` | `false` | When `true`, requires the parameter to be passed by client. |
107
+ | `:allow_blank` | `boolean` | `true` | When `false`, expects parameter's value not to be `nil` or empty. |
108
+ | `:default` | Depends on param type | `nil` | Allows to specify default value to set on parameter when parameter have not been sent by client. |
109
+ | `:modifier` | Lambda block | `nil` | Allows to specify a modifier lambda block, which will be used to modify parameter value. |
110
+ ||
111
+ | **String** |
112
+ | `format` | `RegExp` | `nil` | Allows to use a custom `RegExp` to validate parameter value. |
113
+ ||
114
+ | **Number** |
115
+ | `min` | `integer`, `float` | `nil` | Allows to specify a minimum value for the parameter. |
116
+ | `max` | `integer`, `float` | `nil` | Allows to specify a maximum value for the parameter. |
117
+ | `clamp` | `Range`, `integer[2]`, `float[2]` | `[-Float::INFINITY, Float::INFINITY]` | Allows to specify a lower and upper bound for the param value, so that a value outside the bounds will be forced to the nearest bound value. |
118
+ ||
119
+ | **Date** |
120
+ | `min` | `Date`, `DateTime`, `Time`, `Integer`, `Float` | `nil` | Allows to specify a minimum value for the parameter. |
121
+ | `max` | `Date`, `DateTime`, `Time`, `Integer`, `Float` | `nil` | Allows to specify a maximum value for the parameter. |
122
+ | `clamp` | `Range`, `Date[2]`, `DateTime[2]`, `Time[2]`, `Integer[2]`, `Float[2]` | `nil` | Allows to specify a lower and upper bound for the param value, so that a value outside the bounds will be forced to the nearest bound value. |
@@ -24,6 +24,10 @@ module Pastore
24
24
  end
25
25
  end
26
26
 
27
+ def current_role
28
+ self.class.pastore_guards.current_role(self)
29
+ end
30
+
27
31
  class_methods do # rubocop:disable Metrics/BlockLength
28
32
  attr_accessor :_pastore_guards
29
33
 
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pastore
4
+ module Params
5
+ # Stores data about action parameters
6
+ class ActionParam
7
+ AVAILABLE_TYPES = %w[string number boolean date object array any].freeze
8
+ TYPE_ALIASES = {
9
+ 'text' => 'string',
10
+ 'integer' => 'number',
11
+ 'float' => 'number',
12
+ 'hash' => 'object'
13
+ }.freeze
14
+
15
+ attr_reader :name, :type, :modifier, :options, :scope
16
+
17
+ def initialize(name, **options)
18
+ @name = name
19
+ @options = options
20
+ @scope = [@options&.fetch(:scope, nil)].flatten.compact
21
+ @array = @options&.fetch(:array, false) == true
22
+ @modifier = @options&.fetch(:modifier, nil)
23
+ @type = @options&.fetch(:type, :any)
24
+
25
+ check_options!
26
+ end
27
+
28
+ def validate(value)
29
+ Pastore::Params::Validation.validate!(name, type, value, modifier, **options)
30
+ end
31
+
32
+ def array?
33
+ @array
34
+ end
35
+
36
+ def name_with_scope
37
+ [@scope, name].flatten.compact.join('.')
38
+ end
39
+
40
+ private
41
+
42
+ def check_options!
43
+ check_type!
44
+ check_modifier!
45
+ check_required!
46
+ check_default!
47
+ check_min_max!
48
+ check_format!
49
+ check_clamp!
50
+ end
51
+
52
+ def check_type!
53
+ @type = @type.to_s.strip.downcase
54
+
55
+ @type = TYPE_ALIASES[@type] unless TYPE_ALIASES[@type].nil?
56
+
57
+ valid_type = AVAILABLE_TYPES.include?(@type)
58
+ raise Pastore::Params::InvalidParamTypeError, "Invalid param type: #{@type.inspect}" unless valid_type
59
+ end
60
+
61
+ def check_modifier!
62
+ return if @modifier.nil? || @modifier.is_a?(Proc)
63
+
64
+ raise "Invalid modifier, lambda or Proc expected, got #{@modifier.class}"
65
+ end
66
+
67
+ def check_required!
68
+ options[:required] = (options[:required] == true)
69
+ end
70
+
71
+ def check_default!
72
+ return if options[:default].nil?
73
+
74
+ validation = Pastore::Params::Validation.validate!(name, type, options[:default], modifier, **options)
75
+ return if validation.valid?
76
+
77
+ raise Pastore::Params::InvalidValueError, "Invalid default value: #{validation.errors.join(",")}"
78
+ end
79
+
80
+ def check_min_max!
81
+ check_min!
82
+ check_max!
83
+
84
+ return if options[:min].nil? || options[:max].nil?
85
+
86
+ raise 'Invalid min-max range' unless options[:min] <= options[:max]
87
+ end
88
+
89
+ def check_min!
90
+ min = options[:min]
91
+ return if min.nil?
92
+
93
+ raise 'Invalid minimum' unless min.is_a?(Integer) || min.is_a?(Float)
94
+ end
95
+
96
+ def check_max!
97
+ max = options[:max]
98
+ return if max.nil?
99
+
100
+ raise 'Invalid maximum' unless max.is_a?(Integer) || max.is_a?(Float)
101
+ end
102
+
103
+ def check_format!
104
+ return if options[:format].nil?
105
+
106
+ raise 'Invalid format' unless options[:format].is_a?(Regexp)
107
+ end
108
+
109
+ def check_clamp!
110
+ return if options[:clamp].nil?
111
+
112
+ check_clamp_type!
113
+ convert_clamp_to_array!
114
+ normalize_datetime_clamp!
115
+ check_clamp_bounds!
116
+ end
117
+
118
+ def check_clamp_type!
119
+ return if options[:clamp].nil?
120
+ return if [Array, Range].include?(options[:clamp].class)
121
+
122
+ raise Pastore::Params::InvalidValueError, "Invalid clamp value: #{options[:clamp].inspect}"
123
+ end
124
+
125
+ def convert_clamp_to_array!
126
+ clamp = options[:clamp]
127
+
128
+ options[:clamp] = clamp.is_a?(Array) ? [clamp.first, clamp.last] : [clamp.begin, clamp.end]
129
+ end
130
+
131
+ def check_clamp_bounds!
132
+ clamp = options[:clamp]
133
+ return if clamp.first.nil? || clamp.last.nil?
134
+
135
+ raise Pastore::Params::InvalidValueError, "Invalid clamp range: #{clamp.inspect}" if clamp.first > clamp.last
136
+ end
137
+
138
+ def normalize_datetime_clamp!
139
+ return unless @type == 'date'
140
+
141
+ options[:clamp] = options[:clamp].map do |d|
142
+ return d if d.nil?
143
+
144
+ case d
145
+ when Date then d
146
+ when String then DateTime.parse(d.to_s)
147
+ when Integer, Float then Time.at(d).to_datetime
148
+ when Time then d.to_datetime
149
+ else
150
+ raise Pastore::Params::InvalidValueError, "Invalid clamp value: #{options[:clamp].inspect}"
151
+ end
152
+ end
153
+ rescue Date::Error
154
+ raise Pastore::Params::InvalidValueError, "Invalid clamp value: #{options[:clamp].inspect}"
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'action_param'
4
+ require_relative 'validation'
5
+
6
+ module Pastore
7
+ module Params
8
+ # Implements the logic for params settings storage for a controller.
9
+ class Settings
10
+ attr_writer :invalid_params_cbk, :response_status
11
+
12
+ def initialize(superklass)
13
+ @super_params = superklass.pastore_params if superklass.respond_to?(:pastore_params)
14
+ reset!
15
+ end
16
+
17
+ def reset!
18
+ @actions = {}
19
+ @invalid_params_cbk = nil
20
+ @response_status = nil
21
+
22
+ reset_buffer!
23
+ reset_scope!
24
+ end
25
+
26
+ def invalid_params_cbk
27
+ @invalid_params_cbk || @super_params&.invalid_params_cbk
28
+ end
29
+
30
+ def response_status
31
+ @response_status || @super_params&.response_status || :unprocessable_entity
32
+ end
33
+
34
+ def reset_buffer!
35
+ @buffer = []
36
+ end
37
+
38
+ def set_scope(*keys)
39
+ @scope = [keys].flatten.compact.map(&:to_sym)
40
+ end
41
+
42
+ def reset_scope!
43
+ @scope = nil
44
+ end
45
+
46
+ def add(name, **options)
47
+ options = { scope: @scope }.merge(options.symbolize_keys)
48
+
49
+ raise ParamAlreadyDefinedError, "Param #{name} already defined" if @buffer.any? { |p| p.name == name }
50
+
51
+ if @scope.present? && options[:scope].present? && @scope != options[:scope]
52
+ error = "Scope overwrite attempt detected (current_scope: #{@scope}, param_scope: #{options[:scope]}) for param #{name}"
53
+ raise ScopeConflictError, error
54
+ end
55
+
56
+ param = ActionParam.new(name, **options)
57
+
58
+ @buffer << param
59
+ end
60
+
61
+ def save_for(action_name)
62
+ @actions[action_name.to_sym] = @buffer
63
+ reset_buffer!
64
+ end
65
+
66
+ def validate(params, action_name)
67
+ action_params = @actions[action_name.to_sym]
68
+ return {} if action_params.blank?
69
+
70
+ action_params.each_with_object({}) do |validator, errors|
71
+ value = safe_dig(params, *validator.scope, validator.name)
72
+ validation = validator.validate(value)
73
+
74
+ if validation.valid?
75
+ update_param_value!(params, validator, validation)
76
+
77
+ next if validation.errors.empty?
78
+ end
79
+
80
+ errors[validator.name_with_scope] = validation.errors
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def update_param_value!(params, validator, validation)
87
+ if validator.scope.empty?
88
+ params[validator.name] = validation.value
89
+ return
90
+ end
91
+
92
+ # Try to create missing scope keys
93
+ key_path = []
94
+ validator.scope.each do |key|
95
+ params[key] ||= {}
96
+ key_path << key
97
+
98
+ if params[key].is_a?(ActionController::Parameters)
99
+ params = params[key]
100
+ next
101
+ end
102
+
103
+ # if for some reason the scope key is not a hash, we need to add the error to validation errors
104
+ return validation.add_error(:bad_schema, "Invalid param schema at #{key_path.join(".").inspect}")
105
+ end
106
+
107
+ params[validator.name] = validation.value
108
+ end
109
+
110
+ def safe_dig(params, *keys)
111
+ [keys].flatten.reduce(params) do |acc, key|
112
+ acc.respond_to?(:key?) ? acc[key] : nil
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pastore
4
+ module Params
5
+ # Implements the logic of a single param validation
6
+ class Validation
7
+ require_relative 'validations/string_validation'
8
+ require_relative 'validations/number_validation'
9
+ require_relative 'validations/boolean_validation'
10
+ require_relative 'validations/object_validation'
11
+ require_relative 'validations/date_validation'
12
+
13
+ # Validates the value based on the given type and values with the appropriate validator.
14
+ def self.validate!(name, type, value, modifier = nil, **options)
15
+ case type
16
+ when 'string' then Pastore::Params::StringValidation.new(name, value, modifier, **options)
17
+ when 'number' then Pastore::Params::NumberValidation.new(name, value, modifier, **options)
18
+ when 'boolean' then Pastore::Params::BooleanValidation.new(name, value, modifier, **options)
19
+ when 'object' then Pastore::Params::ObjectValidation.new(name, value, modifier, **options)
20
+ when 'date' then Pastore::Params::DateValidation.new(name, value, modifier, **options)
21
+ when 'any' then Validation.new(name, 'any', value, modifier, **options)
22
+ else
23
+ raise Pastore::Params::InvalidValidationTypeError, "Invalid validation type: #{type}"
24
+ end
25
+ end
26
+
27
+ attr_reader :value, :errors
28
+
29
+ def initialize(name, type, value, modifier = nil, **options)
30
+ @name = name
31
+ @type = type
32
+ @modifier = modifier
33
+ @value = value.nil? ? options[:default] : value
34
+ @required = (options[:required] == true) # default: false
35
+ @allow_blank = (options[:allow_blank].nil? || options[:allow_blank]) # default: true
36
+ @allowed_values = options[:in]
37
+ @exclude_values = options[:exclude]
38
+
39
+ @errors = []
40
+
41
+ validate!
42
+ end
43
+
44
+ # Returns true if the value is valid, false otherwise.
45
+ def valid?
46
+ @errors.empty?
47
+ end
48
+
49
+ # Returns true if the value is required, false otherwise.
50
+ def required?
51
+ @required
52
+ end
53
+
54
+ # Adds an error to the list of errors.
55
+ def add_error(error_type, message)
56
+ @errors << { type: 'param', name: @name, value: @value, error: error_type, message: message }
57
+ end
58
+
59
+ private
60
+
61
+ # Performs a basic validation of the value and applies the modifier.
62
+ def validate!
63
+ # check for value presence and if it's allowed to be blank
64
+ check_presence!
65
+ apply_modifier!
66
+ end
67
+
68
+ # Checks if the value is present (not nil) and if it's allowed to be blank.
69
+ def check_presence!
70
+ valid = true
71
+
72
+ # required options ensures that value is present (not nil)
73
+ valid = false if required? && value.nil?
74
+
75
+ # allow_blank option ensures that value is not blank (not empty)
76
+ valid = false if !@allow_blank && value.to_s.strip == ''
77
+
78
+ add_error(:is_blank, "#{@name} cannot be blank") unless valid
79
+
80
+ valid
81
+ end
82
+
83
+ # Applies the modifier to the value.
84
+ def apply_modifier!
85
+ return if @modifier.nil?
86
+
87
+ @value = @modifier.call(@value)
88
+ end
89
+
90
+ # check if value is in the list of allowed values
91
+ def check_allowed_values!
92
+ check_inclusion!
93
+ check_exclusion!
94
+ end
95
+
96
+ def check_inclusion!
97
+ return if @allowed_values.nil?
98
+ return if @allowed_values.include?(value)
99
+
100
+ add_error(:not_allowed, "#{@name} has invalid value: #{value}")
101
+ end
102
+
103
+ def check_exclusion!
104
+ return if @exclude_values.nil?
105
+ return unless @exclude_values.include?(value)
106
+
107
+ add_error(:not_allowed, "#{@name} has invalid value: #{value}")
108
+ end
109
+
110
+ # check if value is a number
111
+ def numeric?
112
+ !Float(value).nil?
113
+ rescue ArgumentError
114
+ false
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pastore
4
+ module Params
5
+ # Implements the validation logic for object parameters.
6
+ class BooleanValidation < Validation
7
+ def initialize(name, value, modifier, **options)
8
+ super(name, 'boolean', value, modifier, **options)
9
+ end
10
+
11
+ private
12
+
13
+ def validate!
14
+ # check for value presence and if it's allowed to be blank
15
+ check_presence!
16
+
17
+ # don't go further if value is blank
18
+ return if value.to_s.strip == ''
19
+
20
+ # check if value is a boolean
21
+ return unless check_if_boolean!
22
+
23
+ # check if value is in the list of allowed values
24
+ check_allowed_values!
25
+
26
+ # apply the modifier
27
+ apply_modifier!
28
+ end
29
+
30
+ def check_if_boolean!
31
+ return true if [true, false].any?(value)
32
+
33
+ if value.is_a?(String) && boolean?
34
+ @value = %w[t true y yes].any?(value.strip.downcase)
35
+ return true
36
+ end
37
+
38
+ add_error(:invalid_type, "#{@name} has invalid type: #{@type} expected")
39
+
40
+ false
41
+ end
42
+
43
+ def boolean?
44
+ %w[t true y yes f false n no].any?(value.strip.downcase)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pastore
4
+ module Params
5
+ # Implements the validation logic for date parameters.
6
+ class DateValidation < Validation
7
+ def initialize(name, value, modifier, **options)
8
+ @min = options[:min]
9
+ @max = options[:max]
10
+ @clamp = options[:clamp]
11
+
12
+ super(name, 'date', value, modifier, **options)
13
+ end
14
+
15
+ private
16
+
17
+ def validate!
18
+ # check for value presence and if it's allowed to be blank
19
+ check_presence!
20
+
21
+ # don't go further if value is blank
22
+ return if value.to_s.strip == ''
23
+
24
+ # check if value is a boolean
25
+ return unless check_if_date! && check_min_max! && check_clamp!
26
+
27
+ # check if value is in the list of allowed values
28
+ check_allowed_values!
29
+
30
+ # apply the modifier
31
+ apply_modifier!
32
+ end
33
+
34
+ def check_if_date!
35
+ return true if [Date, Time, DateTime].include?(value.class)
36
+
37
+ if numeric?
38
+ @value = Time.at(value.to_f).to_datetime
39
+ return true
40
+ end
41
+
42
+ # When value is a string, try to parse it as a DateTime object
43
+ if value.is_a?(String)
44
+ begin
45
+ @value = DateTime.parse(value)
46
+ return true
47
+ rescue Date::Error
48
+ # Do nothing
49
+ end
50
+ end
51
+
52
+ add_error(:invalid_type, "#{@name} has invalid type: #{@type} expected")
53
+
54
+ false
55
+ end
56
+
57
+ def check_min_max!
58
+ min_invalid = @min && value < @min
59
+ max_invalid = @max && value > @max
60
+
61
+ add_error(:too_small, "#{@name} should be greater than #{@min}") if min_invalid
62
+ add_error(:too_large, "#{@name} should be smaller than #{@max}") if max_invalid
63
+
64
+ min_invalid || max_invalid ? false : true
65
+ end
66
+
67
+ def check_clamp!
68
+ return true if @clamp.nil?
69
+
70
+ @value = @value.clamp(@clamp.first, @clamp.last)
71
+ end
72
+ end
73
+ end
74
+ end