active_storage_validations 1.0.4 → 1.1.4

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +126 -48
  3. data/config/locales/de.yml +6 -1
  4. data/config/locales/en.yml +5 -1
  5. data/config/locales/es.yml +6 -1
  6. data/config/locales/fr.yml +6 -1
  7. data/config/locales/it.yml +6 -1
  8. data/config/locales/ja.yml +6 -1
  9. data/config/locales/nl.yml +6 -1
  10. data/config/locales/pl.yml +6 -1
  11. data/config/locales/pt-BR.yml +6 -1
  12. data/config/locales/ru.yml +7 -2
  13. data/config/locales/sv.yml +23 -0
  14. data/config/locales/tr.yml +6 -1
  15. data/config/locales/uk.yml +6 -1
  16. data/config/locales/vi.yml +6 -1
  17. data/config/locales/zh-CN.yml +6 -1
  18. data/lib/active_storage_validations/aspect_ratio_validator.rb +57 -27
  19. data/lib/active_storage_validations/attached_validator.rb +20 -5
  20. data/lib/active_storage_validations/concerns/errorable.rb +38 -0
  21. data/lib/active_storage_validations/concerns/symbolizable.rb +12 -0
  22. data/lib/active_storage_validations/content_type_validator.rb +47 -7
  23. data/lib/active_storage_validations/dimension_validator.rb +61 -30
  24. data/lib/active_storage_validations/limit_validator.rb +49 -5
  25. data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +128 -0
  26. data/lib/active_storage_validations/matchers/attached_validator_matcher.rb +54 -23
  27. data/lib/active_storage_validations/matchers/concerns/active_storageable.rb +17 -0
  28. data/lib/active_storage_validations/matchers/concerns/allow_blankable.rb +26 -0
  29. data/lib/active_storage_validations/matchers/concerns/contextable.rb +35 -0
  30. data/lib/active_storage_validations/matchers/concerns/messageable.rb +26 -0
  31. data/lib/active_storage_validations/matchers/concerns/rspecable.rb +25 -0
  32. data/lib/active_storage_validations/matchers/concerns/validatable.rb +48 -0
  33. data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +75 -32
  34. data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +99 -52
  35. data/lib/active_storage_validations/matchers/size_validator_matcher.rb +96 -31
  36. data/lib/active_storage_validations/matchers.rb +1 -0
  37. data/lib/active_storage_validations/metadata.rb +42 -28
  38. data/lib/active_storage_validations/processable_image_validator.rb +16 -10
  39. data/lib/active_storage_validations/size_validator.rb +32 -9
  40. data/lib/active_storage_validations/version.rb +1 -1
  41. data/lib/active_storage_validations.rb +3 -0
  42. metadata +29 -4
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'concerns/active_storageable.rb'
4
+ require_relative 'concerns/allow_blankable.rb'
5
+ require_relative 'concerns/contextable.rb'
6
+ require_relative 'concerns/messageable.rb'
7
+ require_relative 'concerns/rspecable.rb'
8
+ require_relative 'concerns/validatable.rb'
9
+
10
+ module ActiveStorageValidations
11
+ module Matchers
12
+ def validate_aspect_ratio_of(name, expected_aspect_ratio)
13
+ AspectRatioValidatorMatcher.new(name, expected_aspect_ratio)
14
+ end
15
+
16
+ class AspectRatioValidatorMatcher
17
+ include ActiveStorageable
18
+ include AllowBlankable
19
+ include Contextable
20
+ include Messageable
21
+ include Rspecable
22
+ include Validatable
23
+
24
+ def initialize(attribute_name)
25
+ initialize_allow_blankable
26
+ initialize_contextable
27
+ initialize_messageable
28
+ initialize_rspecable
29
+ @attribute_name = attribute_name
30
+ @allowed_aspect_ratios = @rejected_aspect_ratios = []
31
+ end
32
+
33
+ def description
34
+ "validate the aspect ratios allowed on :#{@attribute_name}."
35
+ end
36
+
37
+ def failure_message
38
+ "is expected to validate aspect ratio of :#{@attribute_name}"
39
+ end
40
+
41
+ def allowing(*aspect_ratios)
42
+ @allowed_aspect_ratios = aspect_ratios.flatten
43
+ self
44
+ end
45
+
46
+ def rejecting(*aspect_ratios)
47
+ @rejected_aspect_ratios = aspect_ratios.flatten
48
+ self
49
+ end
50
+
51
+ def matches?(subject)
52
+ @subject = subject.is_a?(Class) ? subject.new : subject
53
+
54
+ is_a_valid_active_storage_attribute? &&
55
+ is_context_valid? &&
56
+ is_allowing_blank? &&
57
+ is_custom_message_valid? &&
58
+ all_allowed_aspect_ratios_allowed? &&
59
+ all_rejected_aspect_ratios_rejected?
60
+ end
61
+
62
+ protected
63
+
64
+ def all_allowed_aspect_ratios_allowed?
65
+ @allowed_aspect_ratios_not_allowed ||= @allowed_aspect_ratios.reject { |aspect_ratio| aspect_ratio_allowed?(aspect_ratio) }
66
+ @allowed_aspect_ratios_not_allowed.empty?
67
+ end
68
+
69
+ def all_rejected_aspect_ratios_rejected?
70
+ @rejected_aspect_ratios_not_rejected ||= @rejected_aspect_ratios.select { |aspect_ratio| aspect_ratio_allowed?(aspect_ratio) }
71
+ @rejected_aspect_ratios_not_rejected.empty?
72
+ end
73
+
74
+ def aspect_ratio_allowed?(aspect_ratio)
75
+ width, height = valid_width_and_height_for(aspect_ratio)
76
+
77
+ mock_dimensions_for(attach_file, width, height) do
78
+ validate
79
+ is_valid?
80
+ end
81
+ end
82
+
83
+ def is_custom_message_valid?
84
+ return true unless @custom_message
85
+
86
+ mock_dimensions_for(attach_file, -1, -1) do
87
+ validate
88
+ has_an_error_message_which_is_custom_message?
89
+ end
90
+ end
91
+
92
+ def attach_file
93
+ @subject.public_send(@attribute_name).attach(dummy_file)
94
+ @subject.public_send(@attribute_name)
95
+ end
96
+
97
+ def dummy_file
98
+ {
99
+ io: Tempfile.new('Hello world!'),
100
+ filename: 'test.png',
101
+ content_type: 'image/png'
102
+ }
103
+ end
104
+
105
+ def mock_dimensions_for(attachment, width, height)
106
+ Matchers.mock_metadata(attachment, width, height) do
107
+ yield
108
+ end
109
+ end
110
+
111
+ def valid_width_and_height_for(aspect_ratio)
112
+ case aspect_ratio
113
+ when :square then [100, 100]
114
+ when :portrait then [100, 200]
115
+ when :landscape then [200, 100]
116
+ when validator_class::ASPECT_RATIO_REGEX
117
+ aspect_ratio =~ validator_class::ASPECT_RATIO_REGEX
118
+ x = Regexp.last_match(1).to_i
119
+ y = Regexp.last_match(2).to_i
120
+
121
+ [100 * x, 100 * y]
122
+ else
123
+ [-1, -1]
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -1,5 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'concerns/active_storageable.rb'
4
+ require_relative 'concerns/contextable.rb'
5
+ require_relative 'concerns/messageable.rb'
6
+ require_relative 'concerns/rspecable.rb'
7
+ require_relative 'concerns/validatable.rb'
8
+
3
9
  module ActiveStorageValidations
4
10
  module Matchers
5
11
  def validate_attached_of(name)
@@ -7,52 +13,77 @@ module ActiveStorageValidations
7
13
  end
8
14
 
9
15
  class AttachedValidatorMatcher
16
+ include ActiveStorageable
17
+ include Contextable
18
+ include Messageable
19
+ include Rspecable
20
+ include Validatable
21
+
10
22
  def initialize(attribute_name)
23
+ initialize_contextable
24
+ initialize_messageable
25
+ initialize_rspecable
11
26
  @attribute_name = attribute_name
12
27
  end
13
28
 
14
29
  def description
15
- "validate #{@attribute_name} must be attached"
30
+ "validate that :#{@attribute_name} must be attached"
31
+ end
32
+
33
+ def failure_message
34
+ "is expected to validate attachment of :#{@attribute_name}"
16
35
  end
17
36
 
18
37
  def matches?(subject)
19
38
  @subject = subject.is_a?(Class) ? subject.new : subject
20
- responds_to_methods && valid_when_attached && invalid_when_not_attached
39
+
40
+ is_a_valid_active_storage_attribute? &&
41
+ is_context_valid? &&
42
+ is_custom_message_valid? &&
43
+ is_valid_when_file_attached? &&
44
+ is_invalid_when_file_not_attached?
21
45
  end
22
46
 
23
- def failure_message
24
- "is expected to validate attached of #{@attribute_name}"
47
+ private
48
+
49
+ def is_valid_when_file_attached?
50
+ attach_dummy_file unless file_attached?
51
+ validate
52
+ is_valid?
25
53
  end
26
54
 
27
- def failure_message_when_negated
28
- "is expected to not validate attached of #{@attribute_name}"
55
+ def is_invalid_when_file_not_attached?
56
+ detach_file if file_attached?
57
+ validate
58
+ !is_valid?
29
59
  end
30
60
 
31
- private
61
+ def is_custom_message_valid?
62
+ return true unless @custom_message
63
+
64
+ detach_file if file_attached?
65
+ validate
66
+ has_an_error_message_which_is_custom_message?
67
+ end
32
68
 
33
- def responds_to_methods
34
- @subject.respond_to?(@attribute_name) &&
35
- @subject.public_send(@attribute_name).respond_to?(:attach) &&
36
- @subject.public_send(@attribute_name).respond_to?(:detach)
69
+ def attach_dummy_file
70
+ dummy_file = {
71
+ io: Tempfile.new('.'),
72
+ filename: 'dummy.txt',
73
+ content_type: 'text/plain'
74
+ }
75
+
76
+ @subject.public_send(@attribute_name).attach(dummy_file)
37
77
  end
38
78
 
39
- def valid_when_attached
40
- @subject.public_send(@attribute_name).attach(attachable) unless @subject.public_send(@attribute_name).attached?
41
- @subject.validate
42
- @subject.errors.details[@attribute_name].exclude?(error: :blank)
79
+ def file_attached?
80
+ @subject.public_send(@attribute_name).attached?
43
81
  end
44
82
 
45
- def invalid_when_not_attached
83
+ def detach_file
46
84
  @subject.public_send(@attribute_name).detach
47
85
  # Unset the direct relation since `detach` on an unpersisted record does not set `attached?` to false.
48
86
  @subject.public_send("#{@attribute_name}=", nil)
49
-
50
- @subject.validate
51
- @subject.errors.details[@attribute_name].include?(error: :blank)
52
- end
53
-
54
- def attachable
55
- { io: Tempfile.new('.'), filename: 'dummy.txt', content_type: 'text/plain' }
56
87
  end
57
88
  end
58
89
  end
@@ -0,0 +1,17 @@
1
+ require "active_support/concern"
2
+
3
+ module ActiveStorageValidations
4
+ module Matchers
5
+ module ActiveStorageable
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def is_a_valid_active_storage_attribute?
11
+ @subject.respond_to?(@attribute_name) &&
12
+ @subject.public_send(@attribute_name).respond_to?(:attach) &&
13
+ @subject.public_send(@attribute_name).respond_to?(:detach)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ require "active_support/concern"
2
+
3
+ module ActiveStorageValidations
4
+ module Matchers
5
+ module AllowBlankable
6
+ extend ActiveSupport::Concern
7
+
8
+ def initialize_allow_blankable
9
+ @allow_blank = nil
10
+ end
11
+
12
+ def allow_blank
13
+ @allow_blank = true
14
+ self
15
+ end
16
+
17
+ private
18
+
19
+ def is_allowing_blank?
20
+ return true unless @allow_blank
21
+
22
+ validate
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,35 @@
1
+ require "active_support/concern"
2
+
3
+ module ActiveStorageValidations
4
+ module Matchers
5
+ module Contextable
6
+ extend ActiveSupport::Concern
7
+
8
+ def initialize_contextable
9
+ @context = nil
10
+ end
11
+
12
+ def on(context)
13
+ @context = context
14
+ self
15
+ end
16
+
17
+ private
18
+
19
+ def is_context_valid?
20
+ return true if !@context && !(attribute_validator && attribute_validator.options[:on])
21
+
22
+ raise ArgumentError, "This validator matcher needs the #on option to work since its validator has one" if !@context
23
+ raise ArgumentError, "This validator matcher option only allows a symbol or an array" if !(@context.is_a?(Symbol) || @context.is_a?(Array))
24
+
25
+ if @context.is_a?(Array) && attribute_validator.options[:on].is_a?(Array)
26
+ @context.to_set == attribute_validator.options[:on].to_set
27
+ elsif @context.is_a?(Symbol) && attribute_validator.options[:on].is_a?(Symbol)
28
+ @context == attribute_validator.options[:on]
29
+ else
30
+ false
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,26 @@
1
+ require "active_support/concern"
2
+
3
+ module ActiveStorageValidations
4
+ module Matchers
5
+ module Messageable
6
+ extend ActiveSupport::Concern
7
+
8
+ def initialize_messageable
9
+ @custom_message = nil
10
+ end
11
+
12
+ def with_message(custom_message)
13
+ @custom_message = custom_message
14
+ self
15
+ end
16
+
17
+ private
18
+
19
+ def has_an_error_message_which_is_custom_message?
20
+ validator_errors_for_attribute.one? do |error|
21
+ error[:error] == @custom_message
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,25 @@
1
+ require "active_support/concern"
2
+
3
+ module ActiveStorageValidations
4
+ module Matchers
5
+ module Rspecable
6
+ extend ActiveSupport::Concern
7
+
8
+ def initialize_rspecable
9
+ @failure_message_artefacts = []
10
+ end
11
+
12
+ def description
13
+ raise NotImplementedError, "#{self.class} did not define #{__method__}"
14
+ end
15
+
16
+ def failure_message
17
+ raise NotImplementedError, "#{self.class} did not define #{__method__}"
18
+ end
19
+
20
+ def failure_message_when_negated
21
+ failure_message.sub(/is expected to validate/, 'is expected not to validate')
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,48 @@
1
+ require "active_support/concern"
2
+
3
+ module ActiveStorageValidations
4
+ module Matchers
5
+ module Validatable
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def validate
11
+ @subject.validate(@context)
12
+ end
13
+
14
+ def validator_errors_for_attribute
15
+ @subject.errors.details[@attribute_name].select do |error|
16
+ error[:validator_type] == validator_class.to_sym
17
+ end
18
+ end
19
+
20
+ def is_valid?
21
+ validator_errors_for_attribute.none? do |error|
22
+ error[:error].in?(available_errors)
23
+ end
24
+ end
25
+
26
+ def available_errors
27
+ [
28
+ *validator_class::ERROR_TYPES,
29
+ *error_from_custom_message
30
+ ].compact
31
+ end
32
+
33
+ def validator_class
34
+ self.class.name.gsub(/::Matchers|Matcher/, '').constantize
35
+ end
36
+
37
+ def attribute_validator
38
+ @subject.class.validators_on(@attribute_name).find do |validator|
39
+ validator.class == validator_class
40
+ end
41
+ end
42
+
43
+ def error_from_custom_message
44
+ attribute_validator.options[:message]
45
+ end
46
+ end
47
+ end
48
+ end
@@ -2,6 +2,14 @@
2
2
 
3
3
  # Big thank you to the paperclip validation matchers:
4
4
  # https://github.com/thoughtbot/paperclip/blob/v6.1.0/lib/paperclip/matchers/validate_attachment_content_type_matcher.rb
5
+
6
+ require_relative 'concerns/active_storageable.rb'
7
+ require_relative 'concerns/allow_blankable.rb'
8
+ require_relative 'concerns/contextable.rb'
9
+ require_relative 'concerns/messageable.rb'
10
+ require_relative 'concerns/rspecable.rb'
11
+ require_relative 'concerns/validatable.rb'
12
+
5
13
  module ActiveStorageValidations
6
14
  module Matchers
7
15
  def validate_content_type_of(name)
@@ -9,12 +17,30 @@ module ActiveStorageValidations
9
17
  end
10
18
 
11
19
  class ContentTypeValidatorMatcher
20
+ include ActiveStorageable
21
+ include AllowBlankable
22
+ include Contextable
23
+ include Messageable
24
+ include Rspecable
25
+ include Validatable
26
+
12
27
  def initialize(attribute_name)
28
+ initialize_allow_blankable
29
+ initialize_contextable
30
+ initialize_messageable
31
+ initialize_rspecable
13
32
  @attribute_name = attribute_name
33
+ @allowed_types = @rejected_types = []
14
34
  end
15
35
 
16
36
  def description
17
- "validate the content types allowed on attachment #{@attribute_name}"
37
+ "validate the content types allowed on :#{@attribute_name}"
38
+ end
39
+
40
+ def failure_message
41
+ message = ["is expected to validate the content types of :#{@attribute_name}"]
42
+ build_failure_message(message)
43
+ message.join("\n")
18
44
  end
19
45
 
20
46
  def allowing(*types)
@@ -29,60 +55,77 @@ module ActiveStorageValidations
29
55
 
30
56
  def matches?(subject)
31
57
  @subject = subject.is_a?(Class) ? subject.new : subject
32
- responds_to_methods && allowed_types_allowed? && rejected_types_rejected?
58
+
59
+ is_a_valid_active_storage_attribute? &&
60
+ is_context_valid? &&
61
+ is_allowing_blank? &&
62
+ is_custom_message_valid? &&
63
+ all_allowed_types_allowed? &&
64
+ all_rejected_types_rejected?
33
65
  end
34
66
 
35
- def failure_message
36
- message = ["Expected #{@attribute_name}"]
67
+ protected
37
68
 
38
- if @allowed_types
39
- message << "Accept content types: #{allowed_types.join(", ")}"
40
- message << "#{@missing_allowed_types.join(", ")} were rejected"
69
+ def build_failure_message(message)
70
+ if @allowed_types_not_allowed.present?
71
+ message << " the following content type#{'s' if @allowed_types.count > 1} should be allowed: :#{@allowed_types.join(", :")}"
72
+ message << " but #{pluralize(@allowed_types_not_allowed)} rejected"
41
73
  end
42
74
 
43
- if @rejected_types
44
- message << "Reject content types: #{rejected_types.join(", ")}"
45
- message << "#{@missing_rejected_types.join(", ")} were accepted"
75
+ if @rejected_types_not_rejected.present?
76
+ message << " the following content type#{'s' if @rejected_types.count > 1} should be rejected: :#{@rejected_types.join(", :")}"
77
+ message << " but #{pluralize(@rejected_types_not_rejected)} accepted"
46
78
  end
47
-
48
- message.join("\n")
49
79
  end
50
80
 
51
- protected
81
+ def pluralize(types)
82
+ if types.count == 1
83
+ ":#{types[0]} was"
84
+ else
85
+ ":#{types.join(", :")} were"
86
+ end
87
+ end
52
88
 
53
- def responds_to_methods
54
- @subject.respond_to?(@attribute_name) &&
55
- @subject.public_send(@attribute_name).respond_to?(:attach) &&
56
- @subject.public_send(@attribute_name).respond_to?(:detach)
89
+ def all_allowed_types_allowed?
90
+ @allowed_types_not_allowed ||= @allowed_types.reject { |type| type_allowed?(type) }
91
+ @allowed_types_not_allowed.empty?
57
92
  end
58
93
 
59
- def allowed_types
60
- @allowed_types || []
94
+ def all_rejected_types_rejected?
95
+ @rejected_types_not_rejected ||= @rejected_types.select { |type| type_allowed?(type) }
96
+ @rejected_types_not_rejected.empty?
61
97
  end
62
98
 
63
- def rejected_types
64
- @rejected_types || []
99
+ def type_allowed?(type)
100
+ attach_file_of_type(type)
101
+ validate
102
+ is_valid?
65
103
  end
66
104
 
67
- def allowed_types_allowed?
68
- @missing_allowed_types ||= allowed_types.reject { |type| type_allowed?(type) }
69
- @missing_allowed_types.none?
105
+ def attach_file_of_type(type)
106
+ @subject.public_send(@attribute_name).attach(attachment_for(type))
70
107
  end
71
108
 
72
- def rejected_types_rejected?
73
- @missing_rejected_types ||= rejected_types.select { |type| type_allowed?(type) }
74
- @missing_rejected_types.none?
109
+ def is_custom_message_valid?
110
+ return true unless @custom_message
111
+
112
+ attach_invalid_content_type_file
113
+ validate
114
+ has_an_error_message_which_is_custom_message?
75
115
  end
76
116
 
77
- def type_allowed?(type)
78
- @subject.public_send(@attribute_name).attach(attachment_for(type))
79
- @subject.validate
80
- @subject.errors.details[@attribute_name].all? { |error| error[:error] != :content_type_invalid }
117
+ def attach_invalid_content_type_file
118
+ @subject.public_send(@attribute_name).attach(attachment_for('fake/fake'))
81
119
  end
82
120
 
83
121
  def attachment_for(type)
84
122
  suffix = type.to_s.split('/').last
85
- { io: Tempfile.new('.'), filename: "test.#{suffix}", content_type: type }
123
+
124
+ {
125
+ io: Tempfile.new('.'),
126
+ filename: "test.#{suffix}",
127
+ content_type: type
128
+ }
86
129
  end
87
130
  end
88
131
  end