shrine 2.19.4 → 3.0.0.alpha

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of shrine might be problematic. Click here for more details.

Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +299 -11
  3. data/README.md +9 -3
  4. data/doc/advantages.md +1 -1
  5. data/doc/carrierwave.md +4 -4
  6. data/doc/creating_persistence_plugins.md +172 -0
  7. data/doc/creating_plugins.md +1 -1
  8. data/doc/creating_storages.md +3 -1
  9. data/doc/design.md +2 -2
  10. data/doc/direct_s3.md +0 -22
  11. data/doc/paperclip.md +3 -3
  12. data/doc/plugins/activerecord.md +211 -42
  13. data/doc/plugins/atomic_helpers.md +153 -0
  14. data/doc/plugins/column.md +90 -0
  15. data/doc/plugins/derivation_endpoint.md +54 -62
  16. data/doc/plugins/derivatives.md +752 -0
  17. data/doc/plugins/entity.md +204 -0
  18. data/doc/plugins/infer_extension.md +8 -8
  19. data/doc/plugins/instrumentation.md +33 -13
  20. data/doc/plugins/keep_files.md +5 -15
  21. data/doc/plugins/model.md +157 -0
  22. data/doc/plugins/presign_endpoint.md +2 -1
  23. data/doc/plugins/refresh_metadata.md +44 -7
  24. data/doc/plugins/sequel.md +190 -33
  25. data/doc/plugins/{default_url_options.md → url_options.md} +5 -5
  26. data/doc/processing.md +1 -1
  27. data/doc/release_notes/1.1.0.md +2 -2
  28. data/doc/release_notes/2.15.0.md +1 -1
  29. data/doc/storage/s3.md +2 -2
  30. data/doc/testing.md +1 -1
  31. data/lib/shrine.rb +72 -138
  32. data/lib/shrine/attacher.rb +272 -176
  33. data/lib/shrine/attachment.rb +2 -42
  34. data/lib/shrine/plugins/activerecord.rb +103 -26
  35. data/lib/shrine/plugins/add_metadata.rb +9 -10
  36. data/lib/shrine/plugins/atomic_helpers.rb +111 -0
  37. data/lib/shrine/plugins/attacher_options.rb +55 -0
  38. data/lib/shrine/plugins/backgrounding.rb +147 -115
  39. data/lib/shrine/plugins/cached_attachment_data.rb +6 -9
  40. data/lib/shrine/plugins/column.rb +104 -0
  41. data/lib/shrine/plugins/data_uri.rb +35 -38
  42. data/lib/shrine/plugins/default_storage.rb +18 -12
  43. data/lib/shrine/plugins/default_url.rb +11 -21
  44. data/lib/shrine/plugins/default_url_options.rb +3 -30
  45. data/lib/shrine/plugins/delete_raw.rb +9 -13
  46. data/lib/shrine/plugins/derivation_endpoint.rb +75 -114
  47. data/lib/shrine/plugins/derivatives.rb +576 -0
  48. data/lib/shrine/plugins/determine_mime_type.rb +3 -15
  49. data/lib/shrine/plugins/download_endpoint.rb +83 -131
  50. data/lib/shrine/plugins/dynamic_storage.rb +4 -8
  51. data/lib/shrine/plugins/entity.rb +128 -0
  52. data/lib/shrine/plugins/form_assign.rb +107 -0
  53. data/lib/shrine/plugins/included.rb +4 -3
  54. data/lib/shrine/plugins/infer_extension.rb +10 -17
  55. data/lib/shrine/plugins/instrumentation.rb +45 -25
  56. data/lib/shrine/plugins/keep_files.rb +2 -12
  57. data/lib/shrine/plugins/metadata_attributes.rb +15 -14
  58. data/lib/shrine/plugins/model.rb +137 -0
  59. data/lib/shrine/plugins/module_include.rb +2 -0
  60. data/lib/shrine/plugins/presign_endpoint.rb +1 -15
  61. data/lib/shrine/plugins/pretty_location.rb +5 -5
  62. data/lib/shrine/plugins/processing.rb +21 -6
  63. data/lib/shrine/plugins/rack_file.rb +1 -39
  64. data/lib/shrine/plugins/rack_response.rb +14 -7
  65. data/lib/shrine/plugins/recache.rb +5 -2
  66. data/lib/shrine/plugins/refresh_metadata.rb +12 -8
  67. data/lib/shrine/plugins/remote_url.rb +44 -53
  68. data/lib/shrine/plugins/remove_attachment.rb +7 -2
  69. data/lib/shrine/plugins/remove_invalid.rb +8 -4
  70. data/lib/shrine/plugins/restore_cached_data.rb +12 -4
  71. data/lib/shrine/plugins/sequel.rb +115 -27
  72. data/lib/shrine/plugins/signature.rb +2 -7
  73. data/lib/shrine/plugins/store_dimensions.rb +13 -27
  74. data/lib/shrine/plugins/upload_endpoint.rb +14 -15
  75. data/lib/shrine/plugins/upload_options.rb +9 -8
  76. data/lib/shrine/plugins/url_options.rb +33 -0
  77. data/lib/shrine/plugins/validation.rb +87 -0
  78. data/lib/shrine/plugins/validation_helpers.rb +33 -54
  79. data/lib/shrine/plugins/versions.rb +106 -84
  80. data/lib/shrine/storage/file_system.rb +32 -57
  81. data/lib/shrine/storage/linter.rb +9 -1
  82. data/lib/shrine/storage/memory.rb +42 -0
  83. data/lib/shrine/storage/s3.rb +38 -146
  84. data/lib/shrine/uploaded_file.rb +22 -29
  85. data/lib/shrine/version.rb +4 -4
  86. data/shrine.gemspec +2 -3
  87. metadata +27 -54
  88. data/doc/plugins/backup.md +0 -31
  89. data/doc/plugins/copy.md +0 -24
  90. data/doc/plugins/delete_promoted.md +0 -12
  91. data/doc/plugins/direct_upload.md +0 -172
  92. data/doc/plugins/hooks.md +0 -58
  93. data/doc/plugins/logging.md +0 -42
  94. data/doc/plugins/migration_helpers.md +0 -60
  95. data/doc/plugins/moving.md +0 -19
  96. data/doc/plugins/multi_delete.md +0 -20
  97. data/doc/plugins/parallelize.md +0 -16
  98. data/doc/plugins/parsed_json.md +0 -23
  99. data/lib/shrine/plugins/background_helpers.rb +0 -5
  100. data/lib/shrine/plugins/backup.rb +0 -90
  101. data/lib/shrine/plugins/copy.rb +0 -50
  102. data/lib/shrine/plugins/delete_promoted.rb +0 -20
  103. data/lib/shrine/plugins/direct_upload.rb +0 -217
  104. data/lib/shrine/plugins/hooks.rb +0 -90
  105. data/lib/shrine/plugins/logging.rb +0 -142
  106. data/lib/shrine/plugins/migration_helpers.rb +0 -70
  107. data/lib/shrine/plugins/moving.rb +0 -57
  108. data/lib/shrine/plugins/multi_delete.rb +0 -32
  109. data/lib/shrine/plugins/parallelize.rb +0 -78
  110. data/lib/shrine/plugins/parsed_json.rb +0 -29
@@ -8,9 +8,14 @@ class Shrine
8
8
  #
9
9
  # [doc/plugins/sequel.md]: https://github.com/shrinerb/shrine/blob/master/doc/plugins/sequel.md
10
10
  module Sequel
11
- def self.configure(uploader, opts = {})
12
- uploader.opts[:sequel_callbacks] = opts.fetch(:callbacks, uploader.opts.fetch(:sequel_callbacks, true))
13
- uploader.opts[:sequel_validations] = opts.fetch(:validations, uploader.opts.fetch(:sequel_validations, true))
11
+ def self.load_dependencies(uploader, **)
12
+ uploader.plugin :model
13
+ uploader.plugin :atomic_helpers
14
+ end
15
+
16
+ def self.configure(uploader, **opts)
17
+ uploader.opts[:sequel] ||= { callbacks: true, validations: true }
18
+ uploader.opts[:sequel].merge!(opts)
14
19
  end
15
20
 
16
21
  module AttachmentMethods
@@ -21,64 +26,147 @@ class Shrine
21
26
 
22
27
  name = attachment_name
23
28
 
24
- if shrine_class.opts[:sequel_validations]
29
+ if shrine_class.opts[:sequel][:validations]
25
30
  define_method :validate do
26
31
  super()
27
- send(:"#{name}_attacher").errors.each do |message|
28
- errors.add(name, *message)
32
+ # validation plugin integration
33
+ if send(:"#{name}_attacher").respond_to?(:errors)
34
+ send(:"#{name}_attacher").errors.each do |message|
35
+ errors.add(name, *message)
36
+ end
29
37
  end
30
38
  end
31
39
  end
32
40
 
33
- if shrine_class.opts[:sequel_callbacks]
41
+ if shrine_class.opts[:sequel][:callbacks]
34
42
  define_method :before_save do
35
43
  super()
36
- attacher = send(:"#{name}_attacher")
37
- attacher.save if attacher.changed?
44
+ if send(:"#{name}_attacher").changed?
45
+ send(:"#{name}_attacher").save
46
+ end
38
47
  end
39
48
 
40
49
  define_method :after_save do
41
50
  super()
42
- attacher = send(:"#{name}_attacher")
43
- db.after_commit { attacher.finalize } if attacher.changed?
51
+ if send(:"#{name}_attacher").changed?
52
+ db.after_commit do
53
+ send(:"#{name}_attacher").finalize
54
+ send(:"#{name}_attacher").sequel_persist
55
+ end
56
+ end
44
57
  end
45
58
 
46
59
  define_method :after_destroy do
47
60
  super()
48
- attacher = send(:"#{name}_attacher")
49
- db.after_commit { attacher.destroy } if attacher.read
61
+ if send(:"#{name}_attacher").attached?
62
+ db.after_commit do
63
+ send(:"#{name}_attacher").destroy_attached
64
+ end
65
+ end
50
66
  end
51
67
  end
52
- end
53
- end
54
68
 
55
- module AttacherClassMethods
56
- # Needed by the `backgrounding` plugin.
57
- def find_record(record_class, record_id)
58
- record_class.with_pk(record_id)
69
+ # reload the attacher on record reload
70
+ define_method :_refresh do |*args|
71
+ result = super(*args)
72
+ instance_variable_set(:"@#{name}_attacher", nil)
73
+ result
74
+ end
75
+ private :_refresh
59
76
  end
60
77
  end
61
78
 
62
79
  module AttacherMethods
80
+ # Promotes cached file to permanent storage in an atomic way. It's
81
+ # intended to be called from a background job.
82
+ #
83
+ # attacher.assign(file)
84
+ # attacher.cached? #=> true
85
+ #
86
+ # # ... in background job ...
87
+ #
88
+ # attacher.atomic_promote
89
+ # attacher.stored? #=> true
90
+ #
91
+ # It accepts `:reload` and `:persist` strategies:
92
+ #
93
+ # attacher.atomic_promote(reload: :lock) # uses database locking (default)
94
+ # attacher.atomic_promote(reload: :fetch) # reloads with no locking
95
+ # attacher.atomic_promote(reload: ->(&b){}) # custom reloader
96
+ # attacher.atomic_promote(reload: false) # skips reloading
97
+ #
98
+ # attacher.atomic_promote(persist: :save) # persists stored file (default)
99
+ # attacher.atomic_promote(persist: ->{}) # custom persister
100
+ # attacher.atomic_promote(persist: false) # skips persistence
101
+ def sequel_atomic_promote(**options, &block)
102
+ abstract_atomic_promote(sequel_strategies(**options), &block)
103
+ end
104
+ alias atomic_promote sequel_atomic_promote
105
+
106
+ # Persist the the record only if the attachment hasn't changed.
107
+ # Optionally yields reloaded attacher to the block before persisting.
108
+ # It's intended to be called from a background job.
109
+ #
110
+ # # ... in background job ...
111
+ #
112
+ # attacher.file.metadata["foo"] = "bar"
113
+ # attacher.write
114
+ #
115
+ # attacher.atomic_persist
116
+ def sequel_atomic_persist(*args, **options, &block)
117
+ abstract_atomic_persist(*args, sequel_strategies(**options), &block)
118
+ end
119
+ alias atomic_persist sequel_atomic_persist
120
+
121
+ # Called in the `after_commit` callback after finalization.
122
+ def sequel_persist
123
+ sequel_save
124
+ end
125
+ alias persist sequel_persist
126
+
63
127
  private
64
128
 
65
- # Saves the record after assignment, skipping validations.
66
- def update(uploaded_file)
67
- super
129
+ # Resolves strategies for atomic promotion and persistence.
130
+ def sequel_strategies(reload: :lock, persist: :save, **options)
131
+ reload = method(:"sequel_#{reload}") if reload.is_a?(Symbol)
132
+ persist = method(:"sequel_#{persist}") if persist.is_a?(Symbol)
133
+
134
+ { reload: reload, persist: persist, **options }
135
+ end
136
+
137
+ # Implements the "fetch" reload strategy for #sequel_promote.
138
+ def sequel_fetch
139
+ yield record.dup.refresh
140
+ end
141
+
142
+ # Implements the "lock" reload strategy for #sequel_promote.
143
+ def sequel_lock
144
+ record.db.transaction { yield record.dup.lock! }
145
+ end
146
+
147
+ # Implements the "save" persist strategy for #sequel_promote.
148
+ def sequel_save
68
149
  record.save_changes(validate: false)
69
150
  end
70
151
 
71
- # If the data represents a JSON column with `pg_json` Sequel extension
72
- # loaded, a `Sequel::Postgres::JSONHashBase` object will be returned,
152
+ # Sequel JSON column attribute with `pg_json` Sequel extension loaded
153
+ # returns a `Sequel::Postgres::JSONHashBase` object will be returned,
73
154
  # which we convert into a Hash.
74
- def convert_after_read(value)
75
- sequel_json_column? ? value.to_hash : super
155
+ def deserialize_column(data)
156
+ sequel_json_column? ? data&.to_hash : super
157
+ end
158
+
159
+ # Sequel JSON column attribute with `pg_json` Sequel extension loaded
160
+ # can receive a Hash object, so there is no need to generate a JSON
161
+ # string.
162
+ def serialize_column(data)
163
+ sequel_json_column? ? data : super
76
164
  end
77
165
 
78
166
  # Returns true if the data attribute represents a JSON or JSONB column.
79
167
  def sequel_json_column?
80
168
  return false unless record.is_a?(::Sequel::Model)
81
- return false unless column = record.class.db_schema[data_attribute]
169
+ return false unless column = record.class.db_schema[attribute]
82
170
 
83
171
  [:json, :jsonb].include?(column[:type])
84
172
  end
@@ -15,14 +15,9 @@ class Shrine
15
15
  }.inspect}"
16
16
  end
17
17
 
18
- def self.configure(uploader, opts = {})
19
- uploader.opts[:signature] ||= { log_subscriber: LOG_SUBSCRIBER }
20
- uploader.opts[:signature].merge!(opts)
21
-
18
+ def self.configure(uploader, log_subscriber: LOG_SUBSCRIBER)
22
19
  # instrumentation plugin integration
23
- if uploader.respond_to?(:subscribe)
24
- uploader.subscribe(:signature, &uploader.opts[:signature][:log_subscriber])
25
- end
20
+ uploader.subscribe(:signature, &log_subscriber) if uploader.respond_to?(:subscribe)
26
21
  end
27
22
 
28
23
  module ClassMethods
@@ -13,24 +13,22 @@ class Shrine
13
13
  }.inspect}"
14
14
  end
15
15
 
16
- def self.configure(uploader, opts = {})
17
- uploader.opts[:store_dimensions] ||= { analyzer: :fastimage, on_error: :ignore, log_subscriber: LOG_SUBSCRIBER }
16
+ def self.configure(uploader, log_subscriber: LOG_SUBSCRIBER, **opts)
17
+ uploader.opts[:store_dimensions] ||= { analyzer: :fastimage, on_error: :warn }
18
18
  uploader.opts[:store_dimensions].merge!(opts)
19
19
 
20
20
  # resolve error strategy
21
- case uploader.opts[:store_dimensions][:on_error]
22
- when :fail
23
- uploader.opts[:store_dimensions][:on_error] = -> (error) { fail error }
24
- when :warn
25
- uploader.opts[:store_dimensions][:on_error] = -> (error) { Shrine.warn "Error occurred when attempting to extract image dimensions: #{error.inspect}" }
26
- when :ignore
27
- uploader.opts[:store_dimensions][:on_error] = -> (error) { }
28
- end
21
+ uploader.opts[:store_dimensions][:on_error] =
22
+ case uploader.opts[:store_dimensions][:on_error]
23
+ when :fail then -> (error) { fail error }
24
+ when :warn then -> (error) { Shrine.warn "Error occurred when attempting to extract image dimensions: #{error.inspect}" }
25
+ when :ignore then -> (error) { }
26
+ else
27
+ uploader.opts[:store_dimensions][:on_error]
28
+ end
29
29
 
30
30
  # instrumentation plugin integration
31
- if uploader.respond_to?(:subscribe)
32
- uploader.subscribe(:image_dimensions, &uploader.opts[:store_dimensions][:log_subscriber])
33
- end
31
+ uploader.subscribe(:image_dimensions, &log_subscriber) if uploader.respond_to?(:subscribe)
34
32
  end
35
33
 
36
34
  module ClassMethods
@@ -76,23 +74,11 @@ class Shrine
76
74
 
77
75
  module InstanceMethods
78
76
  # We update the metadata with "width" and "height".
79
- def extract_metadata(io, context = {})
80
- width, height = extract_dimensions(io)
77
+ def extract_metadata(io, **options)
78
+ width, height = self.class.extract_dimensions(io)
81
79
 
82
80
  super.merge!("width" => width, "height" => height)
83
81
  end
84
-
85
- private
86
-
87
- # Extracts dimensions using the specified analyzer.
88
- def extract_dimensions(io)
89
- self.class.extract_dimensions(io)
90
- end
91
-
92
- # Returns a hash of built-in dimensions analyzers.
93
- def dimensions_analyzers
94
- self.class.dimensions_analyzers
95
- end
96
82
  end
97
83
 
98
84
  module FileMethods
@@ -146,7 +146,7 @@ class Shrine
146
146
  # keys, which is to be passed to `Shrine#upload`. Calls
147
147
  # `:upload_context` option if given.
148
148
  def get_context(request)
149
- context = { action: :upload, phase: :upload, request: request }
149
+ context = { action: :upload }
150
150
  context.merge! @upload_context.call(request) if @upload_context
151
151
  context
152
152
  end
@@ -166,26 +166,29 @@ class Shrine
166
166
  # a Rack response triple - an array consisting of a status number, hash
167
167
  # of headers, and a body enumerable. If a `:rack_response` option is
168
168
  # given, calls that instead.
169
- def make_response(object, request)
169
+ def make_response(uploaded_file, request)
170
170
  if @rack_response
171
- @rack_response.call(object, request)
171
+ @rack_response.call(uploaded_file, request)
172
172
  else
173
173
  if @url
174
- url = case @url
175
- when true then object.url
176
- when Hash then object.url(**@url)
177
- else @url.call(object, request)
178
- end
179
-
180
- body = { data: object, url: url }.to_json
174
+ url = resolve_url(uploaded_file, request)
175
+ body = { data: uploaded_file, url: url }.to_json
181
176
  else
182
- body = object.to_json
177
+ body = uploaded_file.to_json
183
178
  end
184
179
 
185
180
  [200, { "Content-Type" => CONTENT_TYPE_JSON }, [body]]
186
181
  end
187
182
  end
188
183
 
184
+ def resolve_url(uploaded_file, request)
185
+ case @url
186
+ when true then uploaded_file.url
187
+ when Hash then uploaded_file.url(**@url)
188
+ else @url.call(uploaded_file, request)
189
+ end
190
+ end
191
+
189
192
  def verify_size!(file, request)
190
193
  error!(413, "Upload Too Large") if @max_size && file.size > @max_size
191
194
  end
@@ -212,8 +215,4 @@ class Shrine
212
215
  @shrine_class.new(@storage_key)
213
216
  end
214
217
  end
215
-
216
- # backwards compatibility
217
- Plugins::UploadEndpoint.const_set(:App, UploadEndpoint)
218
- Plugins::UploadEndpoint.deprecate_constant(:App)
219
218
  end
@@ -12,18 +12,19 @@ class Shrine
12
12
  end
13
13
 
14
14
  module InstanceMethods
15
- def put(io, context)
16
- upload_options = get_upload_options(io, context)
17
- context = { upload_options: upload_options }.merge(context)
18
- super
15
+ def _upload(io, **options)
16
+ upload_options = get_upload_options(io, options)
17
+
18
+ super(io, **options, upload_options: upload_options)
19
19
  end
20
20
 
21
21
  private
22
22
 
23
- def get_upload_options(io, context)
24
- options = opts[:upload_options][storage_key]
25
- options = options.call(io, context) if options.respond_to?(:call)
26
- options
23
+ def get_upload_options(io, options)
24
+ upload_options = opts[:upload_options][storage_key] || {}
25
+ upload_options = upload_options.call(io, options) if upload_options.respond_to?(:call)
26
+ upload_options = upload_options.merge(options[:upload_options]) if options[:upload_options]
27
+ upload_options
27
28
  end
28
29
  end
29
30
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Shrine
4
+ module Plugins
5
+ # Documentation lives in [doc/plugins/url_options.md] on GitHub.
6
+ #
7
+ # [doc/plugins/url_options.md]: https://github.com/shrinerb/shrine/blob/master/doc/plugins/url_options.md
8
+ module UrlOptions
9
+ def self.configure(uploader, **options)
10
+ uploader.opts[:url_options] ||= {}
11
+ uploader.opts[:url_options].merge!(options)
12
+ end
13
+
14
+ module FileMethods
15
+ def url(**options)
16
+ default_options = url_options(options)
17
+
18
+ super(**default_options, **options)
19
+ end
20
+
21
+ private
22
+
23
+ def url_options(options)
24
+ default_options = shrine_class.opts[:url_options][storage_key]
25
+ default_options = default_options.call(self, options) if default_options.respond_to?(:call)
26
+ default_options || {}
27
+ end
28
+ end
29
+ end
30
+
31
+ register_plugin(:url_options, UrlOptions)
32
+ end
33
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Shrine
4
+ module Plugins
5
+ module Validation
6
+ module AttacherClassMethods
7
+ # Block that is executed in context of Shrine::Attacher during
8
+ # validation. Example:
9
+ #
10
+ # Shrine::Attacher.validate do
11
+ # if file.size > 5*1024*1024
12
+ # errors << "is too big (max is 5 MB)"
13
+ # end
14
+ # end
15
+ def validate(&block)
16
+ private define_method(:validate_block, &block)
17
+ end
18
+ end
19
+
20
+ module AttacherMethods
21
+ # Returns an array of validation errors created on file assignment in
22
+ # the `Attacher.validate` block.
23
+ attr_reader :errors
24
+
25
+ # Initializes validation errors to an empty array.
26
+ def initialize(**options)
27
+ super
28
+ @errors = []
29
+ end
30
+
31
+ # Registers options that will be passed to validation.
32
+ def validate_options(options = nil)
33
+ if options
34
+ @validate_options ||= {}
35
+ @validate_options.merge!(options)
36
+ else
37
+ defined?(@validate_options) ? @validate_options : {}
38
+ end
39
+ end
40
+
41
+ # Leaves out :validate option when calling `Shrine.upload`.
42
+ def upload(*args, validate: nil, **options)
43
+ super(*args, **options)
44
+ end
45
+
46
+ # Performs validations after changing the file.
47
+ def change(file, validate: nil, **)
48
+ result = super
49
+ validation(validate)
50
+ result
51
+ end
52
+
53
+ # Runs the validation defined by `Attacher.validate`.
54
+ def validate(**options)
55
+ errors.clear
56
+ _validate(**options) if attached?
57
+ end
58
+
59
+ private
60
+
61
+ # Calls validation appropriately based on the :validate value.
62
+ def validation(argument)
63
+ case argument
64
+ when Hash then validate(argument)
65
+ when false then errors.clear # skip validation
66
+ else validate
67
+ end
68
+ end
69
+
70
+ # Calls #validate_block, passing it accepted parameters.
71
+ def _validate(**options)
72
+ if method(:validate_block).arity.zero?
73
+ validate_block
74
+ else
75
+ validate_block(**validate_options, **options)
76
+ end
77
+ end
78
+
79
+ # Overridden by the `Attacher.validate` block.
80
+ def validate_block(**options)
81
+ end
82
+ end
83
+ end
84
+
85
+ register_plugin(:validation, Validation)
86
+ end
87
+ end