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,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/request"
4
+ require "hanami/utils/hash"
5
+
6
+ module Hanami
7
+ class Action
8
+ # Provides access to params included in a Rack request.
9
+ #
10
+ # Offers useful access to params via methods like {#[]}, {#get} and {#to_h}.
11
+ #
12
+ # These params are available via {Request#params}.
13
+ #
14
+ # This class is used by default when {Hanami::Action::Validatable} is not included, or when no
15
+ # {Validatable::ClassMethods#params params} validation schema is defined.
16
+ #
17
+ # @see Hanami::Action::Request#params
18
+
19
+ # A set of params requested by the client.
20
+ #
21
+ # Extracts the relevant params from a Rack env (query string, request body, and route params
22
+ # placed in `env["router.params"]` by the router), or from a plain hash passed for convenience
23
+ # in tests.
24
+ #
25
+ # @since 0.1.0
26
+ class Params
27
+ # @api private
28
+ EMPTY_PARAMS = {}.freeze
29
+
30
+ # Params errors
31
+ #
32
+ # @since 1.1.0
33
+ class Errors < SimpleDelegator
34
+ # @since 1.1.0
35
+ # @api private
36
+ def initialize(errors = {})
37
+ super(errors.dup)
38
+ end
39
+
40
+ # Add an error to the param validations
41
+ #
42
+ # This has a semantic similar to `Hash#dig` where you use a set of keys
43
+ # to get a nested value, here you use a set of keys to set a nested
44
+ # value.
45
+ #
46
+ # @param args [Array<Symbol, String>] an array of arguments: the last
47
+ # one is the message to add (String), while the beginning of the array
48
+ # is made of keys to reach the attribute.
49
+ #
50
+ # @raise [ArgumentError] when try to add a message for a key that is
51
+ # already filled with incompatible message type.
52
+ # This usually happens with nested attributes: if you have a `:book`
53
+ # schema and the input doesn't include data for `:book`, the messages
54
+ # will be `["is missing"]`. In that case you can't add an error for a
55
+ # key nested under `:book`.
56
+ #
57
+ # @since 1.1.0
58
+ #
59
+ # @example Basic usage
60
+ # require "hanami/action"
61
+ #
62
+ # class MyAction < Hanami::Action
63
+ # params do
64
+ # required(:book).schema do
65
+ # required(:isbn).filled(:str?)
66
+ # end
67
+ # end
68
+ #
69
+ # def handle(request, response)
70
+ # # 1. Don't try to save the record if the params aren't valid
71
+ # return unless request.params.valid?
72
+ #
73
+ # BookRepository.new.create(request.params[:book])
74
+ # rescue Hanami::Model::UniqueConstraintViolationError
75
+ # # 2. Add an error in case the record wasn't unique
76
+ # request.params.errors.add(:book, :isbn, "is not unique")
77
+ # end
78
+ # end
79
+ #
80
+ # @example Invalid argument
81
+ # require "hanami/action"
82
+ #
83
+ # class MyAction < Hanami::Action
84
+ # params do
85
+ # required(:book).schema do
86
+ # required(:title).filled(:str?)
87
+ # end
88
+ # end
89
+ #
90
+ # def handle(request, *)
91
+ # puts request.params.to_h # => {}
92
+ # puts request.params.valid? # => false
93
+ # puts request.params.error_messages # => ["Book is missing"]
94
+ # puts request.params.errors # => {:book=>["is missing"]}
95
+ #
96
+ # request.params.errors.add(:book, :isbn, "is not unique") # => ArgumentError
97
+ # end
98
+ # end
99
+ def add(*args)
100
+ *keys, key, error = args
101
+ _nested_attribute(keys, key) << error
102
+ rescue TypeError
103
+ raise ArgumentError.new("Can't add #{args.map(&:inspect).join(', ')} to #{inspect}")
104
+ end
105
+
106
+ private
107
+
108
+ # @since 1.1.0
109
+ # @api private
110
+ def _nested_attribute(keys, key)
111
+ if keys.empty?
112
+ self
113
+ else
114
+ keys.inject(self) { |result, k| result[k] ||= {} }
115
+ dig(*keys)
116
+ end[key] ||= []
117
+ end
118
+ end
119
+
120
+ # @attr_reader env [Hash] the Rack env
121
+ #
122
+ # @since 0.7.0
123
+ # @api private
124
+ attr_reader :env
125
+
126
+ # @attr_reader raw [Hash] the raw params from the request
127
+ #
128
+ # @since 0.7.0
129
+ # @api private
130
+ attr_reader :raw
131
+
132
+ # Returns structured error messages
133
+ #
134
+ # @return [Hash]
135
+ #
136
+ # @since 0.7.0
137
+ #
138
+ # @example
139
+ # params.errors
140
+ # # => {
141
+ # :email=>["is missing", "is in invalid format"],
142
+ # :name=>["is missing"],
143
+ # :tos=>["is missing"],
144
+ # :age=>["is missing"],
145
+ # :address=>["is missing"]
146
+ # }
147
+ attr_reader :errors
148
+
149
+ # Initialize the params and freeze them.
150
+ #
151
+ # @param env [Hash] a Rack env or an hash of params.
152
+ #
153
+ # @return [Params]
154
+ #
155
+ # @since 0.1.0
156
+ # @api private
157
+ def initialize(env:, contract: nil)
158
+ @env = env
159
+ @raw = _extract_params
160
+
161
+ if contract
162
+ validation = contract.call(raw)
163
+ @params = validation.to_h
164
+ @errors = Errors.new(validation.errors.to_h)
165
+ else
166
+ @params = raw.empty? ? EMPTY_PARAMS : Utils::Hash.deep_symbolize(raw)
167
+ @errors = Errors.new
168
+ end
169
+
170
+ freeze
171
+ end
172
+
173
+ # Returns the value for the given params key.
174
+ #
175
+ # @param key [Symbol] the key
176
+ #
177
+ # @return [Object,nil] the associated value, if found
178
+ #
179
+ # @since 0.7.0
180
+ # @api public
181
+ def [](key)
182
+ @params[key]
183
+ end
184
+
185
+ # Returns an value associated with the given params key.
186
+ #
187
+ # You can access nested attributes by listing all the keys in the path. This uses the same key
188
+ # path semantics as `Hash#dig`.
189
+ #
190
+ # @param keys [Array<Symbol,Integer>] the key
191
+ #
192
+ # @return [Object,NilClass] return the associated value, if found
193
+ #
194
+ # @example
195
+ # require "hanami/action"
196
+ #
197
+ # module Deliveries
198
+ # class Create < Hanami::Action
199
+ # def handle(request, *)
200
+ # request.params.get(:customer_name) # => "Luca"
201
+ # request.params.get(:uknown) # => nil
202
+ #
203
+ # request.params.get(:address, :city) # => "Rome"
204
+ # request.params.get(:address, :unknown) # => nil
205
+ #
206
+ # request.params.get(:tags, 0) # => "foo"
207
+ # request.params.get(:tags, 1) # => "bar"
208
+ # request.params.get(:tags, 999) # => nil
209
+ #
210
+ # request.params.get(nil) # => nil
211
+ # end
212
+ # end
213
+ # end
214
+ #
215
+ # @since 0.7.0
216
+ # @api public
217
+ def get(*keys)
218
+ @params.dig(*keys)
219
+ end
220
+
221
+ # This is for compatibility with Hanami::Helpers::FormHelper::Values
222
+ #
223
+ # @api private
224
+ # @since 0.8.0
225
+ alias_method :dig, :get
226
+
227
+ # Returns flat collection of full error messages
228
+ #
229
+ # @return [Array]
230
+ #
231
+ # @since 0.7.0
232
+ #
233
+ # @example
234
+ # params.error_messages
235
+ # # => [
236
+ # "Email is missing",
237
+ # "Email is in invalid format",
238
+ # "Name is missing",
239
+ # "Tos is missing",
240
+ # "Age is missing",
241
+ # "Address is missing"
242
+ # ]
243
+ def error_messages(error_set = errors)
244
+ error_set.each_with_object([]) do |(key, messages), result|
245
+ k = Utils::String.titleize(key)
246
+
247
+ msgs = if messages.is_a?(::Hash)
248
+ error_messages(messages)
249
+ else
250
+ messages.map { |message| "#{k} #{message}" }
251
+ end
252
+
253
+ result.concat(msgs)
254
+ end
255
+ end
256
+
257
+ # Returns true if no validation errors are found,
258
+ # false otherwise.
259
+ #
260
+ # @return [TrueClass, FalseClass]
261
+ #
262
+ # @since 0.7.0
263
+ #
264
+ # @example
265
+ # params.valid? # => true
266
+ def valid?
267
+ errors.empty?
268
+ end
269
+
270
+ # Iterates over the params.
271
+ #
272
+ # Calls the given block with each param key-value pair; returns the full hash of params.
273
+ #
274
+ # @yieldparam key [Symbol]
275
+ # @yieldparam value [Object]
276
+ #
277
+ # @return [to_h]
278
+ #
279
+ # @since 0.7.1
280
+ # @api public
281
+ def each(&blk)
282
+ to_h.each(&blk)
283
+ end
284
+
285
+ # Serialize validated params to Hash
286
+ #
287
+ # @return [::Hash]
288
+ #
289
+ # @since 0.3.0
290
+ def to_h
291
+ @params
292
+ end
293
+ alias_method :to_hash, :to_h
294
+
295
+ # Pattern-matching support
296
+ #
297
+ # @return [::Hash]
298
+ #
299
+ # @since 2.0.2
300
+ def deconstruct_keys(*)
301
+ to_hash
302
+ end
303
+
304
+ private
305
+
306
+ def _extract_params # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
307
+ # Without PATH_INFO (URL path from a server) or rack.input (request body), env has no
308
+ # Rack-sourced data for us to parse as params; it's likely a bare hash passed to
309
+ # `Action#call` for convenience in tests. In this case, treat the env itself as the params.
310
+ return env unless env.key?(PATH_INFO) || env.key?(RACK_INPUT)
311
+
312
+ query_string = env[::Rack::QUERY_STRING]
313
+ has_query = query_string && !query_string.empty?
314
+ has_router_params = env.key?(ROUTER_PARAMS)
315
+ has_body_params = env.key?(ACTION_BODY_PARAMS)
316
+ # Only a form-urlencoded/multipart body yields params via Rack; other bodies (e.g. JSON)
317
+ # are handled by BodyParser, which sets ACTION_BODY_PARAMS.
318
+ has_form_body =
319
+ env.key?(RACK_INPUT) && !has_body_params && _form_content_type?(env[CONTENT_TYPE])
320
+
321
+ # Fast path: nothing in env produces params, so avoid allocating a Rack::Request.
322
+ return EMPTY_PARAMS unless has_query || has_form_body || has_router_params || has_body_params
323
+
324
+ result = {}
325
+ rack_request = ::Rack::Request.new(env) if has_query || has_form_body
326
+
327
+ result.merge!(rack_request.GET) if has_query
328
+ result.merge!(rack_request.POST) if has_form_body
329
+ result.merge!(env[ROUTER_PARAMS]) if has_router_params
330
+ result.merge!(env[ACTION_BODY_PARAMS]) if has_body_params
331
+
332
+ result
333
+ end
334
+
335
+ def _form_content_type?(content_type)
336
+ return false unless content_type
337
+
338
+ content_type.start_with?("application/x-www-form-urlencoded", "multipart/form-data")
339
+ end
340
+ end
341
+ end
342
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/files"
4
+
5
+ module Hanami
6
+ class Action
7
+ # Rack extensions for actions.
8
+ #
9
+ # @api private
10
+ # @since 0.4.3
11
+ module Rack
12
+ # File to be sent
13
+ #
14
+ # @see Hanami::Action::Response#send_file
15
+ #
16
+ # @since 0.4.3
17
+ # @api private
18
+ class File
19
+ # @param path [String,Pathname] file path
20
+ #
21
+ # @since 0.4.3
22
+ # @api private
23
+ def initialize(path, root)
24
+ @file = ::Rack::Files.new(root.to_s)
25
+ @path = path.to_s
26
+ end
27
+
28
+ # @since 0.4.3
29
+ # @api private
30
+ def call(env)
31
+ env = env.dup
32
+ env[Action::PATH_INFO] = @path
33
+
34
+ @file.get(env)
35
+ rescue Errno::ENOENT
36
+ [Action::NOT_FOUND, {}, nil]
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Action
5
+ # @since 2.2.0
6
+ # @api private
7
+ def self.rack_3?
8
+ defined?(::Rack::Headers)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Hanami
6
+ class Action
7
+ class Request < ::Rack::Request
8
+ # Wrapper for Rack-provided sessions, allowing access using symbol keys.
9
+ #
10
+ # @since 2.3.0
11
+ # @api public
12
+ class Session
13
+ extend Forwardable
14
+
15
+ def_delegators \
16
+ :@session,
17
+ :clear,
18
+ :delete,
19
+ :empty?,
20
+ :size,
21
+ :length,
22
+ :each,
23
+ :to_h,
24
+ :inspect,
25
+ :keys,
26
+ :values
27
+
28
+ def initialize(session)
29
+ @session = session
30
+ end
31
+
32
+ def [](key)
33
+ @session[key.to_s]
34
+ end
35
+
36
+ def []=(key, value)
37
+ @session[key.to_s] = value
38
+ end
39
+
40
+ def key?(key)
41
+ @session.key?(key.to_s)
42
+ end
43
+
44
+ alias_method :has_key?, :key?
45
+ alias_method :include?, :key?
46
+
47
+ def ==(other)
48
+ Utils::Hash.deep_symbolize(@session) == Utils::Hash.deep_symbolize(other)
49
+ end
50
+
51
+ private
52
+
53
+ # Provides a fallback for any methods not handled by the def_delegators.
54
+ def method_missing(method_name, *args, &block)
55
+ if @session.respond_to?(method_name)
56
+ @session.send(method_name, *args, &block)
57
+ else
58
+ super
59
+ end
60
+ end
61
+
62
+ def respond_to_missing?(method_name, include_private = false)
63
+ @session.respond_to?(method_name, include_private) || super
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/mime"
4
+ require "rack/request"
5
+ require "rack/utils"
6
+ require "securerandom"
7
+ require_relative "errors"
8
+
9
+ module Hanami
10
+ class Action
11
+ # The HTTP request for an action, given to {Action#handle}.
12
+ #
13
+ # Inherits from `Rack::Request`, providing compatibility with Rack functionality.
14
+ #
15
+ # @see http://www.rubydoc.info/gems/rack/Rack/Request
16
+ #
17
+ # @since 0.3.1
18
+ class Request < ::Rack::Request
19
+ # Returns the request's params.
20
+ #
21
+ # @return [Params]
22
+ #
23
+ # @since 2.0.0
24
+ # @api public
25
+ attr_reader :params
26
+
27
+ # @since 2.0.0
28
+ # @api private
29
+ def initialize(env:, params:, default_tld_length: 1, session_enabled: false)
30
+ super(env)
31
+
32
+ @params = params
33
+ @session_enabled = session_enabled
34
+ @default_tld_length = default_tld_length
35
+ end
36
+
37
+ # Returns the request's ID
38
+ #
39
+ # @return [String]
40
+ #
41
+ # @since 2.0.0
42
+ # @api public
43
+ def id
44
+ # FIXME: make this number configurable and document the probabilities of clashes
45
+ @id ||= @env[Action::REQUEST_ID] = SecureRandom.hex(Action::DEFAULT_ID_LENGTH)
46
+ end
47
+
48
+ # Returns true if the session is enabled for the request.
49
+ #
50
+ # @return [Boolean]
51
+ #
52
+ # @api public
53
+ # @since 2.1.0
54
+ def session_enabled?
55
+ @session_enabled
56
+ end
57
+
58
+ # Returns the session for the request.
59
+ #
60
+ # @return [Hanami::Request::Session] the session object
61
+ #
62
+ # @raise [MissingSessionError] if the session is not enabled
63
+ #
64
+ # @see #session_enabled?
65
+ # @see Response#session
66
+ #
67
+ # @since 2.0.0
68
+ # @api public
69
+ def session
70
+ unless session_enabled?
71
+ raise Hanami::Action::MissingSessionError.new("Hanami::Action::Request#session")
72
+ end
73
+
74
+ @session ||= Session.new(super)
75
+ end
76
+
77
+ # Returns the flash for the request.
78
+ #
79
+ # @return [Flash]
80
+ #
81
+ # @raise [MissingSessionError] if sessions are not enabled
82
+ #
83
+ # @see Response#flash
84
+ #
85
+ # @since 2.0.0
86
+ # @api public
87
+ def flash
88
+ unless session_enabled?
89
+ raise Hanami::Action::MissingSessionError.new("Hanami::Action::Request#flash")
90
+ end
91
+
92
+ @flash ||= Flash.new(session[Flash::KEY])
93
+ end
94
+
95
+ # Returns the subdomains for the current host.
96
+ #
97
+ # @return [Array<String>]
98
+ #
99
+ # @api public
100
+ # @since 2.3.0
101
+ def subdomains(tld_length = @default_tld_length)
102
+ return [] if IP_ADDRESS_HOST_REGEXP.match?(host)
103
+
104
+ host.split(".")[0..-(tld_length + 2)]
105
+ end
106
+
107
+ IP_ADDRESS_HOST_REGEXP = /\A\d+\.\d+\.\d+\.\d+\z/
108
+ private_constant :IP_ADDRESS_HOST_REGEXP
109
+
110
+ # Returns the subdomain for the current host.
111
+ #
112
+ # @return [String]
113
+ #
114
+ # @api public
115
+ # @since 2.3.0
116
+ def subdomain(tld_length = @default_tld_length)
117
+ subdomains(tld_length).join(".")
118
+ end
119
+
120
+ # @since 2.0.0
121
+ # @api private
122
+ def accept?(mime_type)
123
+ !!::Rack::Utils.q_values(accept).find do |mime, _|
124
+ ::Rack::Mime.match?(mime_type, mime)
125
+ end
126
+ end
127
+
128
+ # @since 2.0.0
129
+ # @api private
130
+ def accept_header?
131
+ accept != Action::DEFAULT_ACCEPT
132
+ end
133
+
134
+ # @since 0.1.0
135
+ # @api private
136
+ def accept
137
+ @accept ||= @env[Action::HTTP_ACCEPT] || Action::DEFAULT_ACCEPT
138
+ end
139
+ end
140
+ end
141
+ end