active_storage_validations 1.1.2 → 1.1.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +86 -59
  3. data/lib/active_storage_validations/aspect_ratio_validator.rb +47 -22
  4. data/lib/active_storage_validations/attached_validator.rb +12 -3
  5. data/lib/active_storage_validations/concerns/errorable.rb +38 -0
  6. data/lib/active_storage_validations/concerns/symbolizable.rb +8 -6
  7. data/lib/active_storage_validations/content_type_validator.rb +41 -6
  8. data/lib/active_storage_validations/dimension_validator.rb +15 -15
  9. data/lib/active_storage_validations/limit_validator.rb +44 -7
  10. data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +128 -0
  11. data/lib/active_storage_validations/matchers/attached_validator_matcher.rb +20 -23
  12. data/lib/active_storage_validations/matchers/concerns/active_storageable.rb +17 -0
  13. data/lib/active_storage_validations/matchers/concerns/allow_blankable.rb +26 -0
  14. data/lib/active_storage_validations/matchers/concerns/contextable.rb +35 -0
  15. data/lib/active_storage_validations/matchers/concerns/messageable.rb +26 -0
  16. data/lib/active_storage_validations/matchers/concerns/rspecable.rb +25 -0
  17. data/lib/active_storage_validations/matchers/concerns/validatable.rb +45 -43
  18. data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +39 -25
  19. data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +61 -44
  20. data/lib/active_storage_validations/matchers/size_validator_matcher.rb +41 -24
  21. data/lib/active_storage_validations/matchers.rb +1 -0
  22. data/lib/active_storage_validations/metadata.rb +42 -28
  23. data/lib/active_storage_validations/processable_image_validator.rb +14 -5
  24. data/lib/active_storage_validations/size_validator.rb +7 -6
  25. data/lib/active_storage_validations/version.rb +1 -1
  26. data/lib/active_storage_validations.rb +1 -1
  27. metadata +9 -3
  28. data/lib/active_storage_validations/error_handler.rb +0 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 288f21f798861bd7e44ad225052620bfcfd2d58efe73f0aeda8f4964ace98f56
4
- data.tar.gz: b19cce430aae6c1eed3c2d29cbafa6d07b36423f122157603d4391a892267d64
3
+ metadata.gz: 305a698168303d4889b8ba6b3972201842af8679758f6f9c81140658a0abdc8a
4
+ data.tar.gz: 414b60017f2da20463daa783e56028c927c2090a80e7728877f569323ec0b60a
5
5
  SHA512:
6
- metadata.gz: e7d6e8ac601a1bd25c2c4f4a199782e7b8dd5821ce99a2bcf14a60c0d8dd2f5e55c46d1eeae758438f8e9da34ae2f9366bf4690345dde242af897cf405dc1624
7
- data.tar.gz: a4335b501b1298517053f45ec6f1192d053cd73fd682bc19127f5891aa7704e2116659991e29c3d2c4bd6ff9acf0847b91389b6bbfafb52d2c45a941dbb9dd35
6
+ metadata.gz: 48123a7a880a4f8e1b7680d3ea21b2e54c6f13b884b351e11c3f912fcdeec01efe24a0d6fc26b1021ec9b92460f6c6df321c869d9a8ce4e1848d339b1e35b0ca
7
+ data.tar.gz: faee600c94d2534a943feb4eebe14574341e3920e9bfd03fe663afad85c80e01c8cb7f95ced957ce333a323553798c6d73c0f8bffb33bdc185b922047f176b20
data/README.md CHANGED
@@ -68,15 +68,14 @@ end
68
68
 
69
69
  ### More examples
70
70
 
71
- - Content type validation using symbols. In order to infer the correct mime type from the symbol, the types must be registered with `Marcel::EXTENSIONS` (`MimeMagic::EXTENSIONS` for Rails <= 6.1.3).
71
+ - Content type validation using symbols or regex. The symbol types must be registered by [`Marcel::EXTENSIONS`](https://github.com/rails/marcel/blob/main/lib/marcel/tables.rb) that's used by this gem to infer the full content type.
72
72
 
73
73
  ```ruby
74
74
  class User < ApplicationRecord
75
75
  has_one_attached :avatar
76
76
  has_many_attached :photos
77
77
 
78
- validates :avatar, attached: true, content_type: :png # Marcel::Magic.by_extension(:png).to_s => 'image/png'
79
- # Rails <= 6.1.3; MimeMagic.by_extension(:png).to_s => 'image/png'
78
+ validates :avatar, attached: true, content_type: :png
80
79
  # or
81
80
  validates :photos, attached: true, content_type: [:png, :jpg, :jpeg]
82
81
  # or
@@ -174,12 +173,24 @@ en:
174
173
  image_not_processable: "is not a valid image"
175
174
  ```
176
175
 
177
- In some cases, Active Storage Validations provides variables to help you customize messages:
176
+ In several cases, Active Storage Validations provides variables to help you customize messages:
177
+
178
+ ### Aspect ratio
179
+ The keys starting with `aspect_ratio_` support two variables that you can use:
180
+ - `aspect_ratio` containing the expected aspect ratio, especially usefull for custom aspect ratio
181
+ - `filename` containing the current file name
182
+
183
+ For example :
184
+
185
+ ```yml
186
+ aspect_ratio_is_not: "must be a %{aspect_ratio} image"
187
+ ```
178
188
 
179
189
  ### Content type
180
- The `content_type_invalid` key has two variables that you can use:
190
+ The `content_type_invalid` key has three variables that you can use:
181
191
  - `content_type` containing the content type of the sent file
182
192
  - `authorized_types` containing the list of authorized content types
193
+ - `filename` containing the current file name
183
194
 
184
195
  For example :
185
196
 
@@ -187,22 +198,27 @@ For example :
187
198
  content_type_invalid: "has an invalid content type : %{content_type}, authorized types are %{authorized_types}"
188
199
  ```
189
200
 
190
- ### Number of files
191
- The `limit_out_of_range` key supports two variables that you can use:
192
- - `min` containing the minimum number of files
193
- - `max` containing the maximum number of files
201
+ ### Dimension
202
+ The keys starting with `dimension_` support six variables that you can use:
203
+ - `min` containing the minimum width or height allowed
204
+ - `max` containing the maximum width or height allowed
205
+ - `width` containing the minimum or maximum width allowed
206
+ - `height` containing the minimum or maximum width allowed
207
+ - `lenght` containing the exact width or height allowed
208
+ - `filename` containing the current file name
194
209
 
195
210
  For example :
196
211
 
197
212
  ```yml
198
- limit_out_of_range: "total number is out of range. range: [%{min}, %{max}]"
213
+ dimension_min_inclusion: "must be greater than or equal to %{width} x %{height} pixel."
199
214
  ```
200
215
 
201
216
  ### File size
202
- The keys starting with `file_size_not_` support three variables that you can use:
217
+ The keys starting with `file_size_not_` support four variables that you can use:
203
218
  - `file_size` containing the current file size
204
219
  - `min` containing the minimum file size
205
220
  - `max` containing the maxmimum file size
221
+ - `filename` containing the current file name
206
222
 
207
223
  For example :
208
224
 
@@ -210,14 +226,25 @@ For example :
210
226
  file_size_not_between: "file size must be between %{min_size} and %{max_size} (current size is %{file_size})"
211
227
  ```
212
228
 
213
- ### Aspect ratio
214
- The keys starting with `aspect_ratio_` support one variable that you can use:
215
- - `aspect_ratio` containing the expected aspect ratio, especially usefull for custom aspect ratio
229
+ ### Number of files
230
+ The `limit_out_of_range` key supports two variables that you can use:
231
+ - `min` containing the minimum number of files
232
+ - `max` containing the maximum number of files
216
233
 
217
234
  For example :
218
235
 
219
236
  ```yml
220
- aspect_ratio_is_not: "must be a %{aspect_ratio} image"
237
+ limit_out_of_range: "total number is out of range. range: [%{min}, %{max}]"
238
+ ```
239
+
240
+ ### Processable image
241
+ The `image_not_processable` key supports one variable that you can use:
242
+ - `filename` containing the current file name
243
+
244
+ For example :
245
+
246
+ ```yml
247
+ image_not_processable: "is not a valid image (file: %{filename})"
221
248
  ```
222
249
 
223
250
  ## Installation
@@ -247,7 +274,7 @@ Very simple example of validation with file attached, content type check and cus
247
274
  [![Sample](https://raw.githubusercontent.com/igorkasyanchuk/active_storage_validations/master/docs/preview.png)](https://raw.githubusercontent.com/igorkasyanchuk/active_storage_validations/master/docs/preview.png)
248
275
 
249
276
  ## Test matchers
250
- Provides RSpec-compatible and Minitest-compatible matchers for testing the validators.
277
+ Provides RSpec-compatible and Minitest-compatible matchers for testing the validators. Only `aspect_ratio`, `attached`, `content_type`, `dimension` and `size` validators currently have their matcher developped.
251
278
 
252
279
  ### RSpec
253
280
 
@@ -265,38 +292,65 @@ RSpec.configure do |config|
265
292
  end
266
293
  ```
267
294
 
268
- Example (Note that the options are chainable):
295
+ Matcher methods available:
269
296
 
270
297
  ```ruby
271
298
  describe User do
299
+ # aspect_ratio:
300
+ # #allowing, #rejecting
301
+ it { is_expected.to validate_aspect_ratio_of(:avatar).allowing(:square) }
302
+ it { is_expected.to validate_aspect_ratio_of(:avatar).rejecting(:portrait) }
303
+
304
+ # attached
272
305
  it { is_expected.to validate_attached_of(:avatar) }
273
- it { is_expected.to validate_attached_of(:avatar).with_message('must not be blank') }
274
306
 
307
+ # content_type:
308
+ # #allowing, #rejecting
275
309
  it { is_expected.to validate_content_type_of(:avatar).allowing('image/png', 'image/gif') }
276
310
  it { is_expected.to validate_content_type_of(:avatar).rejecting('text/plain', 'text/xml') }
277
- it { is_expected.to validate_content_type_of(:avatar).rejecting('text/plain', 'text/xml').with_message("must be an authorized type") }
278
311
 
312
+ # dimension:
313
+ # #width, #height, #width_min, #height_min, #width_max, #height_max, #width_between, #height_between
279
314
  it { is_expected.to validate_dimensions_of(:avatar).width(250) }
280
315
  it { is_expected.to validate_dimensions_of(:avatar).height(200) }
281
- it { is_expected.to validate_dimensions_of(:avatar).width(250).height(200).with_message('Invalid dimensions.') }
282
316
  it { is_expected.to validate_dimensions_of(:avatar).width_min(200) }
283
- it { is_expected.to validate_dimensions_of(:avatar).width_max(500) }
284
317
  it { is_expected.to validate_dimensions_of(:avatar).height_min(100) }
318
+ it { is_expected.to validate_dimensions_of(:avatar).width_max(500) }
285
319
  it { is_expected.to validate_dimensions_of(:avatar).height_max(300) }
286
320
  it { is_expected.to validate_dimensions_of(:avatar).width_between(200..500) }
287
321
  it { is_expected.to validate_dimensions_of(:avatar).height_between(100..300) }
288
322
 
323
+ # size:
324
+ # #less_than, #less_than_or_equal_to, #greater_than, #greater_than_or_equal_to, #between
289
325
  it { is_expected.to validate_size_of(:avatar).less_than(50.kilobytes) }
290
326
  it { is_expected.to validate_size_of(:avatar).less_than_or_equal_to(50.kilobytes) }
291
327
  it { is_expected.to validate_size_of(:avatar).greater_than(1.kilobyte) }
292
- it { is_expected.to validate_size_of(:avatar).greater_than(1.kilobyte).with_message('is not in required file size range') }
293
328
  it { is_expected.to validate_size_of(:avatar).greater_than_or_equal_to(1.kilobyte) }
294
329
  it { is_expected.to validate_size_of(:avatar).between(100..500.kilobytes) }
295
330
  end
296
331
  ```
332
+ (Note that matcher methods are chainable)
333
+
334
+ All matchers can currently be customized with Rails validation options:
335
+
336
+ ```ruby
337
+ describe User do
338
+ # :allow_blank
339
+ it { is_expected.to validate_attached_of(:avatar).allow_blank }
340
+
341
+ # :on
342
+ it { is_expected.to validate_attached_of(:avatar).on(:update) }
343
+ it { is_expected.to validate_attached_of(:avatar).on(%i[update custom]) }
344
+
345
+ # :message
346
+ it { is_expected.to validate_dimensions_of(:avatar).width(250).with_message('Invalid dimensions.') }
347
+ end
348
+ ```
297
349
 
298
350
  ### Minitest
299
- To use the following syntax, make sure you have the [shoulda-context](https://github.com/thoughtbot/shoulda-context) gem up and running. To make use of the matchers you need to require the matchers:
351
+ To use the matchers, make sure you have the [shoulda-context](https://github.com/thoughtbot/shoulda-context) gem up and running.
352
+
353
+ You need to require the matchers:
300
354
 
301
355
  ```ruby
302
356
  require 'active_storage_validations/matchers'
@@ -310,35 +364,7 @@ class ActiveSupport::TestCase
310
364
  end
311
365
  ```
312
366
 
313
- Example (Note that the options are chainable):
314
-
315
- ```ruby
316
- class UserTest < ActiveSupport::TestCase
317
- should validate_attached_of(:avatar)
318
- should validate_attached_of(:avatar).with_message('must not be blank')
319
-
320
- should validate_content_type_of(:avatar).allowing('image/png', 'image/gif')
321
- should validate_content_type_of(:avatar).rejecting('text/plain', 'text/xml')
322
- should validate_content_type_of(:avatar).rejecting('text/plain', 'text/xml').with_message("must be an authorized type")
323
-
324
- should validate_dimensions_of(:avatar).width(250)
325
- should validate_dimensions_of(:avatar).height(200)
326
- should validate_dimensions_of(:avatar).width(250).height(200).with_message('Invalid dimensions.')
327
- should validate_dimensions_of(:avatar).width_min(200)
328
- should validate_dimensions_of(:avatar).width_max(500)
329
- should validate_dimensions_of(:avatar).height_min(100)
330
- should validate_dimensions_of(:avatar).height_max(300)
331
- should validate_dimensions_of(:avatar).width_between(200..500)
332
- should validate_dimensions_of(:avatar).height_between(100..300)
333
-
334
- should validate_size_of(:avatar).less_than(50.kilobytes)
335
- should validate_size_of(:avatar).less_than_or_equal_to(50.kilobytes)
336
- should validate_size_of(:avatar).greater_than(1.kilobyte)
337
- should validate_size_of(:avatar).greater_than(1.kilobyte).with_message('is not in required file size range')
338
- should validate_size_of(:avatar).greater_than_or_equal_to(1.kilobyte)
339
- should validate_size_of(:avatar).between(100..500.kilobytes)
340
- end
341
- ```
367
+ Then you can use the matchers with the syntax specified in the RSpec section, just use `should validate_method` instead of `it { is_expected_to validate_method }` as specified in the [shoulda-context](https://github.com/thoughtbot/shoulda-context) gem.
342
368
 
343
369
  ## Todo
344
370
 
@@ -350,21 +376,21 @@ end
350
376
 
351
377
  To run tests in root folder of gem:
352
378
 
353
- * `BUNDLE_GEMFILE=gemfiles/rails_6_0.gemfile bundle exec rake test` to run for Rails 6.0
354
- * `BUNDLE_GEMFILE=gemfiles/rails_6_1.gemfile bundle exec rake test` to run for Rails 6.1
379
+ * `BUNDLE_GEMFILE=gemfiles/rails_6_1_3_1.gemfile bundle exec rake test` to run for Rails 6.1
355
380
  * `BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle exec rake test` to run for Rails 7.0
381
+ * `BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test` to run for Rails 7.0
356
382
  * `BUNDLE_GEMFILE=gemfiles/rails_next.gemfile bundle exec rake test` to run for Rails main branch
357
383
 
358
384
  Snippet to run in console:
359
385
 
360
- ```
361
- BUNDLE_GEMFILE=gemfiles/rails_6_0.gemfile bundle
362
- BUNDLE_GEMFILE=gemfiles/rails_6_1.gemfile bundle
386
+ ```bash
387
+ BUNDLE_GEMFILE=gemfiles/rails_6_1_3_1.gemfile bundle
363
388
  BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle
389
+ BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle
364
390
  BUNDLE_GEMFILE=gemfiles/rails_next.gemfile bundle
365
- BUNDLE_GEMFILE=gemfiles/rails_6_0.gemfile bundle exec rake test
366
- BUNDLE_GEMFILE=gemfiles/rails_6_1.gemfile bundle exec rake test
391
+ BUNDLE_GEMFILE=gemfiles/rails_6_1_3_1.gemfile bundle exec rake test
367
392
  BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle exec rake test
393
+ BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test
368
394
  BUNDLE_GEMFILE=gemfiles/rails_next.gemfile bundle exec rake test
369
395
  ```
370
396
 
@@ -453,6 +479,7 @@ You are welcome to contribute.
453
479
  - https://github.com/Fonsan
454
480
  - https://github.com/tagliala
455
481
  - https://github.com/ocarreterom
482
+ - https://github.com/aditya-cherukuri
456
483
 
457
484
 
458
485
  ## License
@@ -1,21 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'concerns/errorable.rb'
3
4
  require_relative 'concerns/symbolizable.rb'
4
5
  require_relative 'metadata.rb'
5
6
 
6
7
  module ActiveStorageValidations
7
8
  class AspectRatioValidator < ActiveModel::EachValidator # :nodoc
8
9
  include OptionProcUnfolding
9
- include ErrorHandler
10
+ include Errorable
10
11
  include Symbolizable
11
12
 
12
13
  AVAILABLE_CHECKS = %i[with].freeze
13
- PRECISION = 3
14
+ NAMED_ASPECT_RATIOS = %i[square portrait landscape].freeze
15
+ ASPECT_RATIO_REGEX = /is_([1-9]\d*)_([1-9]\d*)/.freeze
16
+ ERROR_TYPES = %i[
17
+ image_metadata_missing
18
+ aspect_ratio_not_square
19
+ aspect_ratio_not_portrait
20
+ aspect_ratio_not_landscape
21
+ aspect_ratio_is_not
22
+ aspect_ratio_unknown
23
+ ].freeze
24
+ PRECISION = 3.freeze
14
25
 
15
26
  def check_validity!
16
- unless AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }
17
- raise ArgumentError, 'You must pass :with to the validator'
18
- end
27
+ ensure_at_least_one_validator_option
28
+ ensure_aspect_ratio_validity
19
29
  end
20
30
 
21
31
  if Rails.gem_version >= Gem::Version.new('6.0.0')
@@ -29,7 +39,7 @@ module ActiveStorageValidations
29
39
 
30
40
  files.each do |file|
31
41
  metadata = Metadata.new(file).metadata
32
- next if is_valid?(record, attribute, metadata)
42
+ next if is_valid?(record, attribute, file, metadata)
33
43
  break
34
44
  end
35
45
  end
@@ -45,19 +55,17 @@ module ActiveStorageValidations
45
55
  file.analyze; file.reload unless file.analyzed?
46
56
  metadata = file.metadata
47
57
 
48
- next if is_valid?(record, attribute, metadata)
58
+ next if is_valid?(record, attribute, file, metadata)
49
59
  break
50
60
  end
51
61
  end
52
62
  end
53
63
 
54
-
55
64
  private
56
65
 
57
-
58
- def is_valid?(record, attribute, metadata)
66
+ def is_valid?(record, attribute, file, metadata)
59
67
  flat_options = unfold_procs(record, self.options, AVAILABLE_CHECKS)
60
- errors_options = initialize_error_options(options)
68
+ errors_options = initialize_error_options(options, file)
61
69
 
62
70
  if metadata[:width].to_i <= 0 || metadata[:height].to_i <= 0
63
71
  errors_options[:aspect_ratio] = flat_options[:with]
@@ -82,21 +90,38 @@ module ActiveStorageValidations
82
90
  errors_options[:aspect_ratio] = flat_options[:with]
83
91
  add_error(record, attribute, :aspect_ratio_not_landscape, **errors_options)
84
92
 
93
+ when ASPECT_RATIO_REGEX
94
+ flat_options[:with] =~ ASPECT_RATIO_REGEX
95
+ x = $1.to_i
96
+ y = $2.to_i
97
+
98
+ return true if x > 0 && y > 0 && (x.to_f / y).round(PRECISION) == (metadata[:width].to_f / metadata[:height]).round(PRECISION)
99
+
100
+ errors_options[:aspect_ratio] = "#{x}:#{y}"
101
+ add_error(record, attribute, :aspect_ratio_is_not, **errors_options)
85
102
  else
86
- if flat_options[:with] =~ /is_(\d*)_(\d*)/
87
- x = $1.to_i
88
- y = $2.to_i
103
+ errors_options[:aspect_ratio] = flat_options[:with]
104
+ add_error(record, attribute, :aspect_ratio_unknown, **errors_options)
105
+ return false
106
+ end
107
+ end
108
+
109
+ def ensure_at_least_one_validator_option
110
+ unless AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }
111
+ raise ArgumentError, 'You must pass :with to the validator'
112
+ end
113
+ end
89
114
 
90
- return true if (x.to_f / y).round(PRECISION) == (metadata[:width].to_f / metadata[:height]).round(PRECISION)
115
+ def ensure_aspect_ratio_validity
116
+ return true if options[:with]&.is_a?(Proc)
91
117
 
92
- errors_options[:aspect_ratio] = "#{x}x#{y}"
93
- add_error(record, attribute, :aspect_ratio_is_not, **errors_options)
94
- else
95
- errors_options[:aspect_ratio] = flat_options[:with]
96
- add_error(record, attribute, :aspect_ratio_unknown, **errors_options)
97
- end
118
+ unless NAMED_ASPECT_RATIOS.include?(options[:with]) || options[:with] =~ ASPECT_RATIO_REGEX
119
+ raise ArgumentError, <<~ERROR_MESSAGE
120
+ You must pass a valid aspect ratio to the validator
121
+ It should either be a named aspect ratio (#{NAMED_ASPECT_RATIOS.join(', ')})
122
+ Or an aspect ratio like 'is_16_9' (matching /#{ASPECT_RATIO_REGEX.source}/)
123
+ ERROR_MESSAGE
98
124
  end
99
- false
100
125
  end
101
126
  end
102
127
  end
@@ -1,19 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'concerns/errorable.rb'
3
4
  require_relative 'concerns/symbolizable.rb'
4
5
 
5
6
  module ActiveStorageValidations
6
7
  class AttachedValidator < ActiveModel::EachValidator # :nodoc:
7
- include ErrorHandler
8
+ include Errorable
8
9
  include Symbolizable
9
10
 
10
11
  ERROR_TYPES = %i[blank].freeze
11
12
 
13
+ def check_validity!
14
+ %i(allow_nil allow_blank).each do |not_authorized_option|
15
+ if options.include?(not_authorized_option)
16
+ raise ArgumentError, "You cannot pass the :#{not_authorized_option} option to this validator"
17
+ end
18
+ end
19
+ end
20
+
12
21
  def validate_each(record, attribute, _value)
13
- return if record.send(attribute).attached?
22
+ return if record.send(attribute).attached? &&
23
+ !Array.wrap(record.send(attribute)).all? { |file| file.marked_for_destruction? }
14
24
 
15
25
  errors_options = initialize_error_options(options)
16
-
17
26
  add_error(record, attribute, ERROR_TYPES.first, **errors_options)
18
27
  end
19
28
  end
@@ -0,0 +1,38 @@
1
+ module ActiveStorageValidations
2
+ module Errorable
3
+ extend ActiveSupport::Concern
4
+
5
+ def initialize_error_options(options, file = nil)
6
+ not_explicitly_written_options = %i(with in)
7
+ curated_options = options.except(*not_explicitly_written_options)
8
+
9
+ active_storage_validations_options = {
10
+ validator_type: self.class.to_sym,
11
+ custom_message: (options[:message] if options[:message].present?),
12
+ filename: get_filename(file)
13
+ }.compact
14
+
15
+ curated_options.merge(active_storage_validations_options)
16
+ end
17
+
18
+ def add_error(record, attribute, error_type, **errors_options)
19
+ type = errors_options[:custom_message].presence || error_type
20
+ return if record.errors.added?(attribute, type)
21
+
22
+ # You can read https://api.rubyonrails.org/classes/ActiveModel/Errors.html#method-i-add
23
+ # to better understand how Rails model errors work
24
+ record.errors.add(attribute, type, **errors_options)
25
+ end
26
+
27
+ private
28
+
29
+ def get_filename(file)
30
+ return nil unless file
31
+
32
+ case file
33
+ when ActiveStorage::Attached then file.blob.filename.to_s
34
+ when Hash then file[:filename]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,10 +1,12 @@
1
- module Symbolizable
2
- extend ActiveSupport::Concern
1
+ module ActiveStorageValidations
2
+ module Symbolizable
3
+ extend ActiveSupport::Concern
3
4
 
4
- class_methods do
5
- def to_sym
6
- validator_class = self.name.split("::").last
7
- validator_class.sub(/Validator/, '').underscore.to_sym
5
+ class_methods do
6
+ def to_sym
7
+ validator_class = self.name.split("::").last
8
+ validator_class.sub(/Validator/, '').underscore.to_sym
9
+ end
8
10
  end
9
11
  end
10
12
  end
@@ -1,30 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'concerns/errorable.rb'
3
4
  require_relative 'concerns/symbolizable.rb'
4
5
 
5
6
  module ActiveStorageValidations
6
7
  class ContentTypeValidator < ActiveModel::EachValidator # :nodoc:
7
8
  include OptionProcUnfolding
8
- include ErrorHandler
9
+ include Errorable
9
10
  include Symbolizable
10
11
 
11
12
  AVAILABLE_CHECKS = %i[with in].freeze
12
13
  ERROR_TYPES = %i[content_type_invalid].freeze
13
14
 
15
+ def check_validity!
16
+ ensure_exactly_one_validator_option
17
+ ensure_content_types_validity
18
+ end
19
+
14
20
  def validate_each(record, attribute, _value)
15
21
  return true unless record.send(attribute).attached?
16
22
 
17
23
  types = authorized_types(record)
18
24
  return true if types.empty?
19
-
20
- files = Array.wrap(record.send(attribute))
21
25
 
22
- errors_options = initialize_error_options(options)
23
- errors_options[:authorized_types] = types_to_human_format(types)
26
+ files = Array.wrap(record.send(attribute))
24
27
 
25
28
  files.each do |file|
26
29
  next if is_valid?(file, types)
27
30
 
31
+ errors_options = initialize_error_options(options, file)
32
+ errors_options[:authorized_types] = types_to_human_format(types)
28
33
  errors_options[:content_type] = content_type(file)
29
34
  add_error(record, attribute, ERROR_TYPES.first, **errors_options)
30
35
  break
@@ -44,7 +49,7 @@ module ActiveStorageValidations
44
49
 
45
50
  def types_to_human_format(types)
46
51
  types
47
- .map { |type| type.to_s.split('/').last.upcase }
52
+ .map { |type| type.is_a?(Regexp) ? type.source : type.to_s.split('/').last.upcase }
48
53
  .join(', ')
49
54
  end
50
55
 
@@ -58,5 +63,35 @@ module ActiveStorageValidations
58
63
  type == file_type || (type.is_a?(Regexp) && type.match?(file_type.to_s))
59
64
  end
60
65
  end
66
+
67
+ def ensure_exactly_one_validator_option
68
+ unless AVAILABLE_CHECKS.one? { |argument| options.key?(argument) }
69
+ raise ArgumentError, 'You must pass either :with or :in to the validator'
70
+ end
71
+ end
72
+
73
+ def ensure_content_types_validity
74
+ return true if options[:with]&.is_a?(Proc) || options[:in]&.is_a?(Proc)
75
+
76
+ ([options[:with]] || options[:in]).each do |content_type|
77
+ raise ArgumentError, invalid_content_type_message(content_type) if invalid_content_type?(content_type)
78
+ end
79
+ end
80
+
81
+ def invalid_content_type_message(content_type)
82
+ <<~ERROR_MESSAGE
83
+ You must pass valid content types to the validator
84
+ '#{content_type}' is not find in Marcel::EXTENSIONS mimes
85
+ ERROR_MESSAGE
86
+ end
87
+
88
+ def invalid_content_type?(content_type)
89
+ case content_type
90
+ when String, Symbol
91
+ Marcel::MimeType.for(declared_type: content_type.to_s, extension: content_type.to_s) == 'application/octet-stream'
92
+ when Regexp
93
+ false # We always validate regexes
94
+ end
95
+ end
61
96
  end
62
97
  end