sinatra-my-params 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b1f2c6fc4b59ce732264ff359866988b740c93b3dd585c43dcd57ee0834a8a1
4
- data.tar.gz: b9f6692a739211d236c32b1658a66dd71b2f0e4e40ecb22010d63031bc7c03a8
3
+ metadata.gz: 3559e8affd6284a7ae9c07239ce1e4709fdab501899e7fc272efdad7fd25a0f8
4
+ data.tar.gz: 5187c37f8fd9d090d5fccd1c9b954b44bdc284f63dae452b8a9c2810f57fdb9e
5
5
  SHA512:
6
- metadata.gz: 906f99e74e6bede4cbf4ee24dbf281a08d1c4c0856ea18a2434e445c01f0f028aa5b165e8a3219716043b4be82141744e2a10894b5aada1f677c9e0960d504cc
7
- data.tar.gz: a07cdd0764f4f0c39bf83c17aca176204d9469ebc2583bbba7804a12d55f3fc60e8f07896536df5a7fc4f8961082ed91d05e1da8e9a9a9a2cb05ecd8bcc0557c
6
+ metadata.gz: 7621f5ef6dfccb1ac643132708cc8705e4a78e7c52a1ea33efd33932c911d78a7dcb8d01f35426a722f956fe3f0b7e524be12745de7385f039431b4f2762835d
7
+ data.tar.gz: a5463391804fa066d6cde70582698de0e144aaa4cf39bc4ffa925bc7e3a11cc9a58381de74d87131afb3effaf0db60c489a91227703b389939b8542c16e64ab9
data/Rakefile CHANGED
@@ -1,13 +1,5 @@
1
- require "rake/testtask"
2
1
  require "rspec/core/rake_task"
3
2
 
4
- Rake::TestTask.new do |t|
5
- t.libs << "test"
6
- end
3
+ RSpec::Core::RakeTask.new(:spec)
7
4
 
8
- begin
9
- RSpec::Core::RakeTask.new(:spec)
10
-
11
- task :default => :spec
12
- rescue LoadError
13
- end
5
+ task :default => :spec
data/lib/permit_params.rb CHANGED
@@ -8,22 +8,46 @@ module PermitParams
8
8
  attr_accessor :param, :options
9
9
  end
10
10
 
11
+ # Raised when a field marked required: true has no value and no
12
+ # default:. Always a subclass of InvalidParameterError, so existing
13
+ # `rescue PermitParams::InvalidParameterError` code keeps working
14
+ # unchanged if you don't care about the distinction.
15
+ class MissingParameterError < InvalidParameterError; end
16
+
11
17
  def permitted_params(params, permitted = {}, strong_validation = false, options = {})
12
18
  return params if permitted.empty?
13
19
 
14
20
  coerced_params = {}
21
+ seen = {}
15
22
 
16
23
  params.each do |key, value|
17
24
  next unless permitted?(permitted: permitted, key: key, value: value)
18
25
 
26
+ spec = normalize_spec(permitted[key.to_sym])
27
+ seen[key.to_sym] = true
28
+
19
29
  coerced = coerce(
20
30
  param: value,
21
- type: permitted[key.to_sym],
31
+ type: spec[:type],
22
32
  strong_validation: strong_validation,
23
- options: options
33
+ options: merge_field_options(options, spec)
24
34
  )
25
- coerced_params[key] = coerced unless coerced.nil?
35
+ coerced = apply_constraints(coerced, spec, raw_value: value, strong_validation: strong_validation)
36
+
37
+ if coerced.nil?
38
+ fill_missing!(coerced_params, key, spec, present: true, raw_value: value)
39
+ else
40
+ coerced_params[key] = coerced
41
+ end
42
+ end
43
+
44
+ permitted.each_key do |perm_key|
45
+ next if seen[perm_key]
46
+
47
+ spec = normalize_spec(permitted[perm_key])
48
+ fill_missing!(coerced_params, perm_key, spec, present: false, raw_value: nil)
26
49
  end
50
+
27
51
  coerced_params
28
52
  end
29
53
 
@@ -41,16 +65,95 @@ module PermitParams
41
65
  DATE_TIME_TYPES = [Date, Time, DateTime].freeze
42
66
  NUMERIC_PARSE_TYPES = [Integer, Float].freeze
43
67
 
68
+ # Per-field overrides that can live inside a Hash spec (e.g.
69
+ # `{ type: Array, of: Integer, delimiter: ';' }`) instead of only in
70
+ # the global 4th-argument `options` hash. Field-level values win.
71
+ FIELD_OPTION_KEYS = %i[delimiter separator integer_precision shape of].freeze
72
+
44
73
  def permitted?(permitted:, key:, value:)
45
74
  permitted.keys.map(&:to_s).include?(key.to_s) && !value.nil?
46
75
  end
47
76
 
77
+ # A permitted value can still just be a bare type (`Integer`, `String`,
78
+ # `Boolean`, ...) exactly like before - that keeps every pre-0.0.12
79
+ # usage working unchanged. It can now ALSO be a Hash describing a
80
+ # richer contract: { type:, required:, default:, in:, match:, min:,
81
+ # max:, of:, shape:, delimiter:, separator:, integer_precision: }.
82
+ def normalize_spec(raw_spec)
83
+ if raw_spec.is_a?(::Hash)
84
+ spec = raw_spec.dup
85
+ spec[:type] = Any unless spec.key?(:type)
86
+ spec
87
+ else
88
+ { type: raw_spec }
89
+ end
90
+ end
91
+
92
+ def merge_field_options(global_options, spec)
93
+ overrides = spec.select { |k, _| FIELD_OPTION_KEYS.include?(k) }
94
+ global_options.merge(overrides)
95
+ end
96
+
97
+ # Called once per permitted field whenever it ends up without a
98
+ # coerced value - either because it failed validation (present: true)
99
+ # or because it was never supplied at all (present: false).
100
+ def fill_missing!(coerced_params, key, spec, present:, raw_value:)
101
+ if spec.key?(:default)
102
+ coerced_params[key] = spec[:default]
103
+ elsif spec[:required]
104
+ if present
105
+ raise InvalidParameterError, "'#{raw_value}' is not a valid #{spec[:type]}"
106
+ else
107
+ raise MissingParameterError, "'#{key}' is required"
108
+ end
109
+ end
110
+ # else: optional and absent/invalid - silently omitted, same as pre-0.0.12
111
+ end
112
+
113
+ def apply_constraints(value, spec, raw_value:, strong_validation:)
114
+ return value if value.nil?
115
+
116
+ violation = constraint_violation(value, spec)
117
+ return value unless violation
118
+
119
+ raise InvalidParameterError, "'#{raw_value}' is not a valid #{spec[:type]} (#{violation})" if strong_validation
120
+
121
+ nil
122
+ end
123
+
124
+ def constraint_violation(value, spec)
125
+ return "must be one of #{spec[:in].inspect}" if spec[:in] && !spec[:in].include?(value)
126
+ return 'does not match required format' if spec[:match] && !(value.is_a?(String) && spec[:match].match?(value))
127
+
128
+ comparable = comparable_value(value)
129
+ return "must be >= #{spec[:min]}" if spec[:min] && comparable && comparable < spec[:min]
130
+ return "must be <= #{spec[:max]}" if spec[:max] && comparable && comparable > spec[:max]
131
+
132
+ nil
133
+ end
134
+
135
+ # What min:/max: compares against: the value itself for numbers, its
136
+ # length for anything else that has one (String, Array, Hash).
137
+ def comparable_value(value)
138
+ return value if value.is_a?(Numeric)
139
+ return value.length if value.respond_to?(:length)
140
+
141
+ nil
142
+ end
143
+
48
144
  def coerce(param:, type:, strong_validation: false, options: {})
49
145
  return param if type == Any
50
146
 
51
147
  begin
52
148
  return nil if param.nil?
53
- return param if begin
149
+
150
+ # `of:` on an Array field means every element needs its own
151
+ # coercion, so an already-Array param (e.g. from a JSON body)
152
+ # can't take the "already the right class" shortcut below.
153
+ of = options[:of]
154
+ array_with_of = type == Array && of
155
+
156
+ return param if !array_with_of && begin
54
157
  param.is_a?(type)
55
158
  rescue StandardError
56
159
  false
@@ -66,17 +169,25 @@ module PermitParams
66
169
  return Date.parse(param) if type == Date
67
170
  return Time.parse(param) if type == Time
68
171
  return DateTime.parse(param) if type == DateTime
69
- return coerce_array(param, options) if type == Array
172
+ return coerce_array(param, options, strong_validation: strong_validation) if type == Array
70
173
  return coerce_shape(param, options) if type == Shape
71
174
  return coerce_hash(param, options) if type == Hash
72
175
  return coerce_boolean(param) if [TrueClass, FalseClass, Boolean].include? type
73
176
 
177
+ nil
178
+ rescue InvalidParameterError
179
+ # Raised by a nested coerce() call (e.g. one bad element inside an
180
+ # `of:` array). Re-raise as-is so the precise inner message survives
181
+ # instead of being replaced by a generic outer one.
182
+ raise if strong_validation
183
+
74
184
  nil
75
185
  rescue StandardError
76
- # Any failure while coercing untrusted input (malformed value,
77
- # unexpected nested shape, missing stdlib constant, etc.) must never
78
- # crash the caller's request handler - it's either rejected loudly
79
- # (strong_validation) or dropped silently, same as an invalid value.
186
+ # Any other failure while coercing untrusted input (malformed
187
+ # value, unexpected nested shape, missing stdlib constant, etc.)
188
+ # must never crash the caller's request handler - it's either
189
+ # rejected loudly (strong_validation) or dropped silently, same
190
+ # as an invalid value.
80
191
  raise InvalidParameterError, "'#{param}' is not a valid #{type}" if strong_validation
81
192
 
82
193
  nil
@@ -87,11 +198,21 @@ module PermitParams
87
198
  Integer(param, options[:integer_precision] || 10)
88
199
  end
89
200
 
90
- def coerce_array(param, options = {})
91
- delimiter = valid_delimiter?(param, options[:delimiter])
92
- return unless delimiter
201
+ def coerce_array(param, options = {}, strong_validation: false)
202
+ elements =
203
+ if param.is_a?(Array)
204
+ param
205
+ else
206
+ delimiter = valid_delimiter?(param, options[:delimiter])
207
+ return unless delimiter
208
+
209
+ param.split(delimiter).map(&:strip)
210
+ end
211
+
212
+ of = options[:of]
213
+ return Array(elements) unless of
93
214
 
94
- Array(param.split(delimiter).map(&:strip))
215
+ elements.map { |el| coerce(param: el, type: of, strong_validation: strong_validation, options: options) }.compact
95
216
  end
96
217
 
97
218
  def coerce_hash(param, options = {})
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'permit_params'
4
+ require 'rspec'
5
+
6
+ describe PermitParams do
7
+ include PermitParams
8
+
9
+ # -- required: ------------------------------------------------------
10
+
11
+ describe 'required:' do
12
+ it 'raises MissingParameterError when a required param is absent' do
13
+ expect do
14
+ permitted_params({}, { email: { type: String, required: true } })
15
+ end.to raise_error(PermitParams::MissingParameterError, "'email' is required")
16
+ end
17
+
18
+ it 'raises MissingParameterError when a required param is explicitly nil' do
19
+ expect do
20
+ permitted_params({ email: nil }, { email: { type: String, required: true } })
21
+ end.to raise_error(PermitParams::MissingParameterError, "'email' is required")
22
+ end
23
+
24
+ it 'raises InvalidParameterError (not MissingParameterError) when present but invalid, even with strong_validation false' do
25
+ expect do
26
+ permitted_params({ age: 'not a number' }, { age: { type: Integer, required: true } })
27
+ end.to raise_error(PermitParams::InvalidParameterError, "'not a number' is not a valid Integer")
28
+ end
29
+
30
+ it 'does not raise when a required param is present and valid' do
31
+ output = permitted_params({ email: 'a@b.com' }, { email: { type: String, required: true } })
32
+ expect(output).to eq(email: 'a@b.com')
33
+ end
34
+
35
+ it 'is a no-op for optional params left unspecified (backward compatible default)' do
36
+ output = permitted_params({}, { nickname: String })
37
+ expect(output).to eq({})
38
+ end
39
+
40
+ it 'MissingParameterError is also an InvalidParameterError, so old rescue clauses still catch it' do
41
+ expect(PermitParams::MissingParameterError.ancestors).to include(PermitParams::InvalidParameterError)
42
+ end
43
+ end
44
+
45
+ # -- default: ---------------------------------------------------------
46
+
47
+ describe 'default:' do
48
+ it 'fills in the default when the param is absent' do
49
+ output = permitted_params({}, { role: { type: String, default: 'member' } })
50
+ expect(output).to eq(role: 'member')
51
+ end
52
+
53
+ it 'fills in the default when the param fails coercion' do
54
+ output = permitted_params({ limit: 'lots' }, { limit: { type: Integer, default: 10 } })
55
+ expect(output).to eq(limit: 10)
56
+ end
57
+
58
+ it 'uses the given value instead of the default when valid' do
59
+ output = permitted_params({ limit: '25' }, { limit: { type: Integer, default: 10 } })
60
+ expect(output).to eq(limit: 25)
61
+ end
62
+
63
+ it 'default takes priority over required (no error raised)' do
64
+ output = permitted_params({}, { role: { type: String, required: true, default: 'member' } })
65
+ expect(output).to eq(role: 'member')
66
+ end
67
+ end
68
+
69
+ # -- in: (enum) ---------------------------------------------------------
70
+
71
+ describe 'in:' do
72
+ let(:permitted) { { status: { type: String, in: %w[draft published] } } }
73
+
74
+ it 'keeps a value that is in the allowed list' do
75
+ output = permitted_params({ status: 'draft' }, permitted)
76
+ expect(output).to eq(status: 'draft')
77
+ end
78
+
79
+ it 'drops a value that is not in the allowed list (lenient)' do
80
+ output = permitted_params({ status: 'deleted' }, permitted)
81
+ expect(output).to eq({})
82
+ end
83
+
84
+ it 'raises with strong_validation when value is not in the allowed list' do
85
+ expect do
86
+ permitted_params({ status: 'deleted' }, permitted, true)
87
+ end.to raise_error(PermitParams::InvalidParameterError, "'deleted' is not a valid String (must be one of [\"draft\", \"published\"])")
88
+ end
89
+ end
90
+
91
+ # -- match: (regex) ------------------------------------------------------
92
+
93
+ describe 'match:' do
94
+ let(:permitted) { { code: { type: String, match: /\A[A-Z]{3}\d{3}\z/ } } }
95
+
96
+ it 'keeps a value that matches the format' do
97
+ output = permitted_params({ code: 'ABC123' }, permitted)
98
+ expect(output).to eq(code: 'ABC123')
99
+ end
100
+
101
+ it 'drops a value that does not match the format (lenient)' do
102
+ output = permitted_params({ code: 'nope' }, permitted)
103
+ expect(output).to eq({})
104
+ end
105
+
106
+ it 'raises with strong_validation when value does not match the format' do
107
+ expect do
108
+ permitted_params({ code: 'nope' }, permitted, true)
109
+ end.to raise_error(PermitParams::InvalidParameterError)
110
+ end
111
+ end
112
+
113
+ # -- min: / max: ----------------------------------------------------------
114
+
115
+ describe 'min: / max:' do
116
+ it 'keeps a numeric value within bounds' do
117
+ output = permitted_params({ age: '25' }, { age: { type: Integer, min: 18, max: 65 } })
118
+ expect(output).to eq(age: 25)
119
+ end
120
+
121
+ it 'drops a numeric value below the minimum' do
122
+ output = permitted_params({ age: '10' }, { age: { type: Integer, min: 18 } })
123
+ expect(output).to eq({})
124
+ end
125
+
126
+ it 'drops a numeric value above the maximum' do
127
+ output = permitted_params({ age: '99' }, { age: { type: Integer, max: 65 } })
128
+ expect(output).to eq({})
129
+ end
130
+
131
+ it 'applies min:/max: to string length' do
132
+ output = permitted_params({ name: 'a' }, { name: { type: String, min: 2 } })
133
+ expect(output).to eq({})
134
+ end
135
+
136
+ it 'applies min:/max: to array length' do
137
+ output = permitted_params({ tags: %w[a b] }, { tags: { type: Array, max: 1 } })
138
+ expect(output).to eq({})
139
+ end
140
+ end
141
+
142
+ # -- of: (typed array elements) --------------------------------------------
143
+
144
+ describe 'of:' do
145
+ it 'coerces each delimited element to the given type' do
146
+ output = permitted_params({ ids: '1,2,3' }, { ids: { type: Array, of: Integer } })
147
+ expect(output).to eq(ids: [1, 2, 3])
148
+ end
149
+
150
+ it 'coerces each element of an already-Array param (e.g. JSON body)' do
151
+ output = permitted_params({ ids: %w[1 2 3] }, { ids: { type: Array, of: Integer } })
152
+ expect(output).to eq(ids: [1, 2, 3])
153
+ end
154
+
155
+ it 'drops individual invalid elements (lenient)' do
156
+ output = permitted_params({ ids: '1,x,3' }, { ids: { type: Array, of: Integer } })
157
+ expect(output).to eq(ids: [1, 3])
158
+ end
159
+
160
+ it 'raises with strong_validation, preserving the precise inner element message' do
161
+ expect do
162
+ permitted_params({ ids: '1,x,3' }, { ids: { type: Array, of: Integer } }, true)
163
+ end.to raise_error(PermitParams::InvalidParameterError, "'x' is not a valid Integer")
164
+ end
165
+
166
+ it 'supports of: Shape for an array of nested objects' do
167
+ permitted = { items: { type: Array, of: PermitParams::Shape, shape: { id: Integer, name: String } } }
168
+ input = { items: [{ id: 1, name: 'a' }, { id: 2, name: 'b' }] }
169
+ output = permitted_params(input, permitted)
170
+ expect(output).to eq(items: [{ id: 1, name: 'a' }, { id: 2, name: 'b' }])
171
+ end
172
+
173
+ it 'drops array elements whose shape does not match' do
174
+ permitted = { items: { type: Array, of: PermitParams::Shape, shape: { id: Integer } } }
175
+ input = { items: [{ id: 1 }, { id: 'not an integer' }] }
176
+ output = permitted_params(input, permitted)
177
+ expect(output).to eq(items: [{ id: 1 }])
178
+ end
179
+ end
180
+
181
+ # -- per-field shape:/delimiter:/separator:/integer_precision: -------------
182
+
183
+ describe 'per-field options override the global options hash' do
184
+ it 'lets two different Shape params use two different shapes at once' do
185
+ permitted = {
186
+ author: { type: PermitParams::Shape, shape: { name: String } },
187
+ book: { type: PermitParams::Shape, shape: { title: String, year: Integer } }
188
+ }
189
+ input = { author: { name: 'Ada' }, book: { title: 'Notes', year: 1843 } }
190
+
191
+ output = permitted_params(input, permitted)
192
+ expect(output).to eq(author: { name: 'Ada' }, book: { title: 'Notes', year: 1843 })
193
+ end
194
+
195
+ it 'falls back to the global options hash shape: when a field does not override it' do
196
+ permitted = { legacy: PermitParams::Shape }
197
+ output = permitted_params({ legacy: { a: 1 } }, permitted, false, shape: { a: Integer })
198
+ expect(output).to eq(legacy: { a: 1 })
199
+ end
200
+
201
+ it 'lets a field override the global delimiter' do
202
+ permitted = { tags: { type: Array, delimiter: ';' } }
203
+ output = permitted_params({ tags: '1; 2' }, permitted, false, delimiter: ',')
204
+ expect(output).to eq(tags: %w[1 2])
205
+ end
206
+ end
207
+
208
+ # -- combinations -----------------------------------------------------------
209
+
210
+ describe 'combined real-world example' do
211
+ it 'validates a small "create user" style payload in one call' do
212
+ permitted = {
213
+ email: { type: String, required: true, match: /\A[^@\s]+@[^@\s]+\z/ },
214
+ role: { type: String, in: %w[member admin], default: 'member' },
215
+ age: { type: Integer, min: 13 },
216
+ tags: { type: Array, of: String, max: 5 }
217
+ }
218
+
219
+ output = permitted_params(
220
+ { email: 'a@b.com', age: '30', tags: 'ruby,sinatra' },
221
+ permitted
222
+ )
223
+
224
+ expect(output).to eq(
225
+ email: 'a@b.com',
226
+ role: 'member',
227
+ age: 30,
228
+ tags: %w[ruby sinatra]
229
+ )
230
+ end
231
+ end
232
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'test/unit'
4
3
  require 'permit_params'
5
4
  require 'rspec'
6
5
  require 'rack/test'
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'test/unit'
4
3
  require 'permit_params'
5
4
  require 'rspec'
6
5
  require 'rack/test'
metadata CHANGED
@@ -1,15 +1,57 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sinatra-my-params
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.11
4
+ version: 0.0.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marco Aviles
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-09-19 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2026-08-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rack-test
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.1'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.12'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.12'
13
55
  description: A simple params sanitizer (originally created for sinatra)
14
56
  email: gdmarav374@gmail.com
15
57
  executables: []
@@ -19,6 +61,7 @@ files:
19
61
  - Rakefile
20
62
  - bin/permit_params
21
63
  - lib/permit_params.rb
64
+ - spec/permit_params_advanced_spec.rb
22
65
  - spec/permit_params_shape_spec.rb
23
66
  - spec/permit_params_spec.rb
24
67
  homepage: https://github.com/mhero/sinatra-my-params
@@ -32,7 +75,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
32
75
  requirements:
33
76
  - - ">="
34
77
  - !ruby/object:Gem::Version
35
- version: '0'
78
+ version: '2.6'
36
79
  required_rubygems_version: !ruby/object:Gem::Requirement
37
80
  requirements:
38
81
  - - ">="
@@ -46,3 +89,4 @@ summary: permit_params!
46
89
  test_files:
47
90
  - spec/permit_params_spec.rb
48
91
  - spec/permit_params_shape_spec.rb
92
+ - spec/permit_params_advanced_spec.rb