hanami-action 3.0.0.rc1

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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +985 -0
  3. data/LICENSE +20 -0
  4. data/README.md +873 -0
  5. data/hanami-action.gemspec +39 -0
  6. data/lib/hanami/action/body_parser/json.rb +20 -0
  7. data/lib/hanami/action/body_parser/multipart_form.rb +22 -0
  8. data/lib/hanami/action/body_parser.rb +109 -0
  9. data/lib/hanami/action/cache/cache_control.rb +84 -0
  10. data/lib/hanami/action/cache/conditional_get.rb +101 -0
  11. data/lib/hanami/action/cache/directives.rb +126 -0
  12. data/lib/hanami/action/cache/expires.rb +84 -0
  13. data/lib/hanami/action/cache.rb +29 -0
  14. data/lib/hanami/action/config/formats.rb +256 -0
  15. data/lib/hanami/action/config.rb +172 -0
  16. data/lib/hanami/action/constants.rb +283 -0
  17. data/lib/hanami/action/cookie_jar.rb +214 -0
  18. data/lib/hanami/action/cookies.rb +27 -0
  19. data/lib/hanami/action/csrf_protection.rb +217 -0
  20. data/lib/hanami/action/errors.rb +109 -0
  21. data/lib/hanami/action/flash.rb +176 -0
  22. data/lib/hanami/action/halt.rb +18 -0
  23. data/lib/hanami/action/mime/request_mime_weight.rb +66 -0
  24. data/lib/hanami/action/mime.rb +438 -0
  25. data/lib/hanami/action/params.rb +342 -0
  26. data/lib/hanami/action/rack/file.rb +41 -0
  27. data/lib/hanami/action/rack_utils.rb +11 -0
  28. data/lib/hanami/action/request/session.rb +68 -0
  29. data/lib/hanami/action/request.rb +141 -0
  30. data/lib/hanami/action/response.rb +481 -0
  31. data/lib/hanami/action/session.rb +47 -0
  32. data/lib/hanami/action/validatable.rb +166 -0
  33. data/lib/hanami/action/version.rb +13 -0
  34. data/lib/hanami/action/view_name_inferrer.rb +56 -0
  35. data/lib/hanami/action.rb +672 -0
  36. data/lib/hanami/http/status.rb +149 -0
  37. data/lib/hanami-action.rb +3 -0
  38. metadata +153 -0
@@ -0,0 +1,481 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "rack/response"
5
+ require "hanami/utils/kernel"
6
+ require_relative "errors"
7
+
8
+ module Hanami
9
+ class Action
10
+ # The HTTP response for an action, given to {Action#handle}.
11
+ #
12
+ # Inherits from `Rack::Response`, providing compatibility with Rack functionality.
13
+ #
14
+ # @see http://www.rubydoc.info/gems/rack/Rack/Response
15
+ #
16
+ # @since 2.0.0
17
+ # @api private
18
+ class Response < ::Rack::Response
19
+ # @since 2.0.0
20
+ # @api private
21
+ DEFAULT_VIEW_OPTIONS = -> (*) { {} }.freeze
22
+
23
+ # @since 2.0.0
24
+ # @api private
25
+ EMPTY_BODY = [].freeze
26
+
27
+ # @since 2.0.0
28
+ # @api private
29
+ FILE_SYSTEM_ROOT = Pathname.new("/").freeze
30
+
31
+ # @since 2.0.0
32
+ # @api private
33
+ attr_reader :request, :exposures, :env, :view_options
34
+
35
+ # @since 2.0.0
36
+ # @api private
37
+ attr_accessor :charset
38
+
39
+ # @since 2.0.0
40
+ # @api private
41
+ def self.build(status, env)
42
+ new(config: Action.config.dup, content_type: Mime.best_q_match(env[Action::HTTP_ACCEPT]), env: env).tap do |r|
43
+ r.status = status
44
+ r.body = Http::Status.message_for(status)
45
+ r.set_format(Mime.format_from_media_type(r.content_type), config)
46
+ end
47
+ end
48
+
49
+ # @since 2.0.0
50
+ # @api private
51
+ def initialize(request:, config:, content_type: nil, charset: nil, env: {}, headers: {}, view_options: nil, session_enabled: false) # rubocop:disable Layout/LineLength
52
+ # `Rack::Response#initialize` copies the headers into its own internal store on both Rack
53
+ # 2.2+ (`Utils::HeaderHash[headers]`) and Rack 3.x (`Headers.new` + per-entry copy), so it
54
+ # never aliases the hash passed in. That means we can skip a defensive `headers.dup` here
55
+ # and avoid an allocation per request without risk of pollution. See the regression spec
56
+ # in `spec/unit/hanami/action/response_spec.rb` if you're tempted to add one back.
57
+ super([], 200, headers)
58
+ self.content_type = content_type if content_type
59
+
60
+ @request = request
61
+ @config = config
62
+ @charset = charset
63
+ @exposures = {}
64
+ @env = env
65
+ @view_options = view_options || DEFAULT_VIEW_OPTIONS
66
+
67
+ @session_enabled = session_enabled
68
+ @sending_file = false
69
+ end
70
+
71
+ # Sets the response body.
72
+ #
73
+ # @param str [String] the body string
74
+ #
75
+ # @since 2.0.0
76
+ # @api public
77
+ def body=(str)
78
+ @length = 0
79
+
80
+ if str.nil? || str == EMPTY_BODY
81
+ @body = EMPTY_BODY
82
+ return
83
+ end
84
+
85
+ @body = []
86
+
87
+ if str.is_a?(::Rack::Files::BaseIterator)
88
+ @body = str
89
+ buffered_body! # Ensure appropriate content-length is set
90
+ else
91
+ write(str)
92
+ end
93
+ end
94
+
95
+ # Sets the response status.
96
+ #
97
+ # @param code [Integer, Symbol] the status code
98
+ #
99
+ # @since 2.0.2
100
+ # @api public
101
+ #
102
+ # @raise [Hanami::Action::UnknownHttpStatusError] if the given code
103
+ # cannot be associated to a known HTTP status
104
+ #
105
+ # @example
106
+ # response.status = :unprocessable_entity
107
+ #
108
+ # @example
109
+ # response.status = 422
110
+ #
111
+ # @see https://guides.hanamirb.org/v2.0/actions/status-codes/
112
+ def status=(code)
113
+ super(Http::Status.lookup(code))
114
+ end
115
+
116
+ # Sets the response body from the rendered view.
117
+ #
118
+ # @param view [Hanami::View] the view to render
119
+ # @param input [Hash] keyword arguments to pass to the view's `#call` method
120
+ #
121
+ # @api public
122
+ # @since 2.1.0
123
+ def render(view, **input)
124
+ view_input = {
125
+ **view_options.call(request, self),
126
+ **exposures,
127
+ **input
128
+ }
129
+
130
+ self.body = view.call(**view_input).to_str
131
+ end
132
+
133
+ # Returns the format for the response.
134
+ #
135
+ # Returns nil if a format has not been assigned and also cannot be determined from the
136
+ # response's `#content_type`.
137
+ #
138
+ # @example
139
+ # response.format # => :json
140
+ #
141
+ # @return [Symbol, nil]
142
+ #
143
+ # @since 2.0.0
144
+ # @api public
145
+ def format
146
+ @format ||= Mime.format_from_media_type(content_type, @config)
147
+ end
148
+
149
+ # Sets the format and associated content type for the response.
150
+ #
151
+ # Either a format name (`:json`) or a MIME type (`"application/json"`) may be given. In either
152
+ # case, the format or content type will be derived from the given value, and both will be set.
153
+ #
154
+ # Providing an unknown format name will raise an {Hanami::Action::UnknownFormatError}.
155
+ #
156
+ # Providing an unknown MIME type will set the content type and set the format as nil.
157
+ #
158
+ # @example Assigning via a format name symbol
159
+ # response.format = :json
160
+ # response.content_type # => "application/json"
161
+ # response.headers["Content-Type"] # => "application/json"
162
+ #
163
+ # @example Assigning via a content type string
164
+ # response.format = "application/json"
165
+ # response.format # => :json
166
+ # response.content_type # => "application/json"
167
+ #
168
+ # @param value [Symbol, String] the format name or content type
169
+ #
170
+ # @raise [Hanami::Action::UnknownFormatError] if an unknown format name is given
171
+ #
172
+ # @see Config#formats
173
+ #
174
+ # @since 2.0.0
175
+ # @api public
176
+ def format=(value)
177
+ format, content_type = Mime.format_and_media_type(value, @config)
178
+
179
+ self.content_type = Mime.content_type_with_charset(content_type, charset)
180
+
181
+ @format = format
182
+ end
183
+
184
+ # Returns the exposure value for the given key.
185
+ #
186
+ # @param key [Object]
187
+ #
188
+ # @return [Object] the exposure value, if found
189
+ #
190
+ # @raise [KeyError] if the exposure was not found
191
+ #
192
+ # @since 2.0.0
193
+ # @api public
194
+ def [](key)
195
+ @exposures.fetch(key)
196
+ end
197
+
198
+ # Sets an exposure value for the given key.
199
+ #
200
+ # @param key [Object]
201
+ # @param value [Object]
202
+ #
203
+ # @return [Object] the value
204
+ #
205
+ # @since 2.0.0
206
+ # @api public
207
+ def []=(key, value)
208
+ @exposures[key] = value
209
+ end
210
+
211
+ # Returns true if the session is enabled for the request.
212
+ #
213
+ # @return [Boolean]
214
+ #
215
+ # @api public
216
+ # @since 2.1.0
217
+ def session_enabled?
218
+ @session_enabled
219
+ end
220
+
221
+ # Returns the session for the response.
222
+ #
223
+ # This is the same session object as the {Request}.
224
+ #
225
+ # @return [Hash] the session object
226
+ #
227
+ # @raise [MissingSessionError] if sessions are not enabled
228
+ #
229
+ # @see Request#session
230
+ #
231
+ # @since 2.0.0
232
+ # @api public
233
+ def session
234
+ unless session_enabled?
235
+ raise Hanami::Action::MissingSessionError.new("Hanami::Action::Response#session")
236
+ end
237
+
238
+ request.session
239
+ end
240
+
241
+ # Returns the flash for the request.
242
+ #
243
+ # This is the same flash object as the {Request}.
244
+ #
245
+ # @return [Flash]
246
+ #
247
+ # @raise [MissingSessionError] if sessions are not enabled
248
+ #
249
+ # @see Request#flash
250
+ #
251
+ # @since 2.0.0
252
+ # @api public
253
+ def flash
254
+ unless session_enabled?
255
+ raise Hanami::Action::MissingSessionError.new("Hanami::Action::Response#flash")
256
+ end
257
+
258
+ request.flash
259
+ end
260
+
261
+ # Returns the set of cookies to be included in the response.
262
+ #
263
+ # @return [CookieJar]
264
+ #
265
+ # @since 2.0.0
266
+ # @api public
267
+ def cookies
268
+ @cookies ||= CookieJar.new(env.dup, headers, @config.cookies)
269
+ end
270
+
271
+ # Sets the response to redirect to the given URL and halts further handling.
272
+ #
273
+ # @param url [String]
274
+ # @param status [Integer] the HTTP status to use for the redirect
275
+ #
276
+ # @since 2.0.0
277
+ # @api public
278
+ def redirect_to(url, status: 302)
279
+ return unless allow_redirect?
280
+
281
+ redirect(::String.new(url), status)
282
+ Halt.call(status)
283
+ end
284
+
285
+ # Sends the file at the given path as the response, for any file within the configured
286
+ # `public_directory`.
287
+ #
288
+ # Handles the following aspects for file responses:
289
+ #
290
+ # - Setting `Content-Type` and `Content-Length` headers
291
+ # - File Not Found responses (returns a 404)
292
+ # - Conditional GET (via `If-Modified-Since` header)
293
+ # - Range requests (via `Range` header)
294
+ #
295
+ # @param path [String] the file path
296
+ #
297
+ # @return [void]
298
+ #
299
+ # @see Hanami::Action::Config#public_directory
300
+ # @see Hanami::Action::Rack::File
301
+ #
302
+ # @since 2.0.0
303
+ # @api public
304
+ def send_file(path)
305
+ _send_file(
306
+ Action::Rack::File.new(path, @config.public_directory).call(env)
307
+ )
308
+ end
309
+
310
+ # Send the file at the given path as the response, for a file anywhere in the file system.
311
+ #
312
+ # @param path [String, Pathname] path to the file to be sent
313
+ #
314
+ # @return [void]
315
+ #
316
+ # @see #send_file
317
+ # @see Hanami::Action::Rack::File
318
+ #
319
+ # @since 2.0.0
320
+ # @api public
321
+ def unsafe_send_file(path)
322
+ directory = if Pathname.new(path).relative?
323
+ @config.root_directory
324
+ else
325
+ FILE_SYSTEM_ROOT
326
+ end
327
+
328
+ _send_file(
329
+ Action::Rack::File.new(path, directory).call(env)
330
+ )
331
+ end
332
+
333
+ # Specifies the response freshness policy for HTTP caches using the `Cache-Control` header.
334
+ #
335
+ # Any number of non-value directives (`:public`, `:private`, `:no_cache`, `:no_store`,
336
+ # `:must_revalidate`, `:proxy_revalidate`) may be passed along with a Hash of value directives
337
+ # (`:max_age`, `:min_stale`, `:s_max_age`).
338
+ #
339
+ # See [RFC 2616 / 14.9](http://tools.ietf.org/html/rfc2616#section-14.9.1) for more on
340
+ # standard cache control directives.
341
+ #
342
+ # @example
343
+ # # Set Cache-Control directives
344
+ # response.cache_control :public, max_age: 900, s_maxage: 86400
345
+ #
346
+ # # Overwrite previous Cache-Control directives
347
+ # response.cache_control :private, :no_cache, :no_store
348
+ #
349
+ # response.get_header("Cache-Control") # => "private, no-store, max-age=900"
350
+ #
351
+ # @param values [Array<Symbol, Hash>] values to map to `Cache-Control` directives
352
+ # @option values [Symbol] :public
353
+ # @option values [Symbol] :private
354
+ # @option values [Symbol] :no_cache
355
+ # @option values [Symbol] :no_store
356
+ # @option values [Symbol] :must_validate
357
+ # @option values [Symbol] :proxy_revalidate
358
+ # @option values [Hash] :max_age
359
+ # @option values [Hash] :min_stale
360
+ # @option values [Hash] :s_max_age
361
+ #
362
+ # @return void
363
+ #
364
+ # @since 2.0.0
365
+ # @api public
366
+ def cache_control(*values)
367
+ directives = Cache::CacheControl::Directives.new(*values)
368
+ headers.merge!(directives.headers)
369
+ end
370
+
371
+ # Sets the `Expires` header and `Cache-Control`/`max-age` directive for the response.
372
+ #
373
+ # You can provide an integer number of seconds in the future, or a Time object indicating when
374
+ # the response should be considered "stale". The remaining arguments are passed to
375
+ # {#cache_control}.
376
+ #
377
+ # @example
378
+ # # Set Cache-Control directives and Expires
379
+ # response.expires 900, :public
380
+ #
381
+ # # Overwrite Cache-Control directives and Expires
382
+ # response.expires 300, :private, :no_cache, :no_store
383
+ #
384
+ # response.get_header("Expires") # => "Thu, 26 Jun 2014 12:00:00 GMT"
385
+ # response.get_header("Cache-Control") # => "private, no-cache, no-store max-age=300"
386
+ #
387
+ # @param amount [Integer, Time] number of seconds or point in time
388
+ # @param values [Array<Symbols>] values to map to `Cache-Control` directives via
389
+ # {#cache_control}
390
+ #
391
+ # @return void
392
+ #
393
+ # @since 2.0.0
394
+ # @api public
395
+ def expires(amount, *values)
396
+ directives = Cache::Expires::Directives.new(amount, *values)
397
+ headers.merge!(directives.headers)
398
+ end
399
+
400
+ # Sets the `etag` and/or `last_modified` headers on the response and halts with a `304 Not
401
+ # Modified` response if the request is still fresh according to the `IfNoneMatch` and
402
+ # `IfModifiedSince` request headers.
403
+ #
404
+ # @example
405
+ # # Set etag header and halt 304 if request matches IF_NONE_MATCH header
406
+ # response.fresh etag: some_resource.updated_at.to_i
407
+ #
408
+ # # Set last_modified header and halt 304 if request matches IF_MODIFIED_SINCE
409
+ # response.fresh last_modified: some_resource.updated_at
410
+ #
411
+ # # Set etag and last_modified header and halt 304 if request matches IF_MODIFIED_SINCE and IF_NONE_MATCH
412
+ # response.fresh last_modified: some_resource.updated_at
413
+ #
414
+ # @param options [Hash]
415
+ # @option options [Integer] :etag for testing IfNoneMatch conditions
416
+ # @option options [Date] :last_modified for testing IfModifiedSince conditions
417
+ #
418
+ # @return void
419
+ #
420
+ # @since 2.0.0
421
+ # @api public
422
+ def fresh(options)
423
+ conditional_get = Cache::ConditionalGet.new(env, options)
424
+
425
+ headers.merge!(conditional_get.headers)
426
+
427
+ conditional_get.fresh? do
428
+ Halt.call(304)
429
+ end
430
+ end
431
+
432
+ # @since 2.0.0
433
+ # @api private
434
+ def set_format(value) # rubocop:disable Naming/AccessorMethodName
435
+ @format = value
436
+ end
437
+
438
+ # @since 2.0.0
439
+ # @api private
440
+ def renderable?
441
+ return !head? && body.empty? if body.respond_to?(:empty?)
442
+
443
+ !@sending_file && !head?
444
+ end
445
+
446
+ # @since 2.0.0
447
+ # @api private
448
+ def allow_redirect?
449
+ return body.empty? if body.respond_to?(:empty?)
450
+
451
+ !@sending_file
452
+ end
453
+
454
+ # @since 2.0.0
455
+ # @api private
456
+ alias_method :to_ary, :to_a
457
+
458
+ # @since 2.0.0
459
+ # @api private
460
+ def head?
461
+ env[Action::REQUEST_METHOD] == Action::HEAD
462
+ end
463
+
464
+ # @since 2.0.0
465
+ # @api private
466
+ def _send_file(send_file_response)
467
+ headers.merge!(send_file_response[Action::RESPONSE_HEADERS])
468
+
469
+ if send_file_response[Action::RESPONSE_CODE] == Action::NOT_FOUND
470
+ headers.delete(Action::X_CASCADE)
471
+ headers.delete(Action::CONTENT_LENGTH)
472
+ Halt.call(Action::NOT_FOUND)
473
+ else
474
+ self.status = send_file_response[Action::RESPONSE_CODE]
475
+ self.body = send_file_response[Action::RESPONSE_BODY]
476
+ @sending_file = true
477
+ end
478
+ end
479
+ end
480
+ end
481
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Action
5
+ # Session support for actions.
6
+ #
7
+ # Not included by default; you should include this module manually to enable session support.
8
+ # For actions within an Hanami app, this module will be included automatically if sessions are
9
+ # configured in the app config.
10
+ #
11
+ # @api public
12
+ # @since 0.1.0
13
+ module Session
14
+ # @api private
15
+ # @since 0.1.0
16
+ def self.included(base)
17
+ base.class_eval do
18
+ before { |req, _| req.id }
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def session_enabled?
25
+ true
26
+ end
27
+
28
+ # Finalize the response
29
+ #
30
+ # @return [void]
31
+ #
32
+ # @since 0.3.0
33
+ # @api private
34
+ #
35
+ # @see Hanami::Action#finish
36
+ def finish(req, res, *)
37
+ if (next_flash = res.flash.next).any?
38
+ res.session[Flash::KEY] = next_flash
39
+ else
40
+ res.session.delete(Flash::KEY)
41
+ end
42
+
43
+ super
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Action
5
+ # Support for validating params when calling actions.
6
+ #
7
+ # Included only when dry-validation is bundled.
8
+ #
9
+ # @api private
10
+ # @since 0.1.0
11
+ module Validatable
12
+ # @api private
13
+ # @since 0.1.0
14
+ def self.included(base)
15
+ base.extend ClassMethods
16
+ end
17
+
18
+ # Validatable API class methods
19
+ #
20
+ # @since 0.1.0
21
+ # @api private
22
+ module ClassMethods
23
+ # Defines a validation schema for the params passed to {Hanami::Action#call}.
24
+ #
25
+ # This feature isn't mandatory, but is highly recommended for secure handling of params:
26
+ # because params come from an untrusted source, it's good practice to filter these to only
27
+ # the keys and types required for your action's use case.
28
+ #
29
+ # The given block is evaluated inside a `params` schema of a `Dry::Validation::Contract`
30
+ # class. This constrains the validation to simple structure and type rules only. If you want
31
+ # to use all the features of dry-validation contracts, use {#contract} instead.
32
+ #
33
+ # Instead of defining the params validation schema inline, you can alternatively provide a
34
+ # concrete `Dry::Validation::Contract` subclass.
35
+ #
36
+ # @param klass [Class,nil] a Dry::Validation::Contract subclass
37
+ # @param block [Proc] the params schema definition
38
+ #
39
+ # @return void
40
+ #
41
+ # @see #contract
42
+ # @see Hanami::Action::Params
43
+ # @see https://dry-rb.org/gems/dry-validation/
44
+ #
45
+ # @example Inline definition
46
+ # require "hanami/action"
47
+ #
48
+ # class Signup < Hanami::Action
49
+ # params do
50
+ # required(:first_name)
51
+ # required(:last_name)
52
+ # required(:email)
53
+ # end
54
+ #
55
+ # def handle(req, *)
56
+ # puts req.params[:first_name] # => "Luca"
57
+ # puts req.params[:admin] # => nil
58
+ # end
59
+ # end
60
+ #
61
+ # @example Concrete class
62
+ # require "hanami/action"
63
+ #
64
+ # class SignupContract < Dry::Validation::Contract
65
+ # params do
66
+ # required(:first_name)
67
+ # required(:last_name)
68
+ # required(:email)
69
+ # end
70
+ # end
71
+ #
72
+ # class Signup < Hanami::Action
73
+ # params SignupContract
74
+ #
75
+ # def handle(req, *)
76
+ # req.params[:first_name] # => "Luca"
77
+ # req.params[:admin] # => nil
78
+ # end
79
+ # end
80
+ #
81
+ # @api public
82
+ # @since 0.3.0
83
+ def params(klass = nil, &block)
84
+ contract_class = klass || Class.new(Dry::Validation::Contract) { params(&block) }
85
+
86
+ config.contract_class = contract_class
87
+ end
88
+
89
+ # Defines a validation contract for the params passed to {Hanami::Action#call}.
90
+ #
91
+ # This feature isn't mandatory, but is highly recommended for secure handling of params:
92
+ # because params come from an untrusted source, it's good practice to filter these to only
93
+ # the keys and types required for your action's use case.
94
+ #
95
+ # The given block is evaluated inside a `Dry::Validation::Contract` class. This allows you
96
+ # to use all features of dry-validation contracts
97
+ #
98
+ # Instead of defining the contract inline, you can alternatively provide a concrete
99
+ # `Dry::Validation::Contract` subclass.
100
+ #
101
+ # @param klass [Class,nil] a Dry::Validation::Contract subclass
102
+ # @param block [Proc] the params schema definition
103
+ #
104
+ # @return void
105
+ #
106
+ # @see #params
107
+ # @see Hanami::Action::Params
108
+ # @see https://dry-rb.org/gems/dry-validation/
109
+ #
110
+ # @example Inline definition
111
+ # require "hanami/action"
112
+ #
113
+ # class Signup < Hanami::Action
114
+ # contract do
115
+ # params do
116
+ # required(:first_name)
117
+ # required(:last_name)
118
+ # required(:email)
119
+ # end
120
+ #
121
+ # rule(:email) do
122
+ # # custom rule logic here
123
+ # end
124
+ # end
125
+ #
126
+ # def handle(req, *)
127
+ # puts req.params[:first_name] # => "Luca"
128
+ # puts req.params[:admin] # => nil
129
+ # end
130
+ # end
131
+ #
132
+ # @example Concrete class
133
+ # require "hanami/action"
134
+ #
135
+ # class SignupContract < Dry::Validation::Contract
136
+ # params do
137
+ # required(:first_name)
138
+ # required(:last_name)
139
+ # required(:email)
140
+ # end
141
+ #
142
+ # rule(:email) do
143
+ # # custom rule logic here
144
+ # end
145
+ # end
146
+ #
147
+ # class Signup < Hanami::Action
148
+ # contract SignupContract
149
+ #
150
+ # def handle(req, *)
151
+ # req.params[:first_name] # => "Luca"
152
+ # req.params[:admin] # => nil
153
+ # end
154
+ # end
155
+ #
156
+ # @api public
157
+ # @since 2.2.0
158
+ def contract(klass = nil, &block)
159
+ contract_class = klass || Class.new(Dry::Validation::Contract, &block)
160
+
161
+ config.contract_class = contract_class
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Action
5
+ # The current hanami-action version.
6
+ #
7
+ # @return [String]
8
+ #
9
+ # @since 3.0.0
10
+ # @api public
11
+ VERSION = "3.0.0.rc1"
12
+ end
13
+ end