hanami-controller 2.0.0.alpha1 → 2.0.0.alpha2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Action
5
+ class ApplicationAction < Module
6
+ attr_reader :provider
7
+ attr_reader :application
8
+
9
+ def initialize(provider)
10
+ @provider = provider
11
+ @application = provider.respond_to?(:application) ? provider.application : Hanami.application
12
+ end
13
+
14
+ def included(action_class)
15
+ action_class.include InstanceMethods
16
+
17
+ define_initialize action_class
18
+ configure_action action_class
19
+ extend_behavior action_class
20
+ end
21
+
22
+ def inspect
23
+ "#<#{self.class.name}[#{provider.name}]>"
24
+ end
25
+
26
+ private
27
+
28
+ def define_initialize(action_class)
29
+ resolve_view = method(:resolve_paired_view)
30
+ resolve_context = method(:resolve_view_context)
31
+
32
+ define_method :initialize do |**deps|
33
+ # Conditionally assign these to repsect any explictly auto-injected
34
+ # dependencies provided by the class
35
+ @view ||= deps[:view] || resolve_view.(self.class)
36
+ @view_context ||= deps[:view_context] || resolve_context.()
37
+
38
+ super(**deps)
39
+ end
40
+ end
41
+
42
+ def resolve_view_context
43
+ identifier = application.config.actions.view_context_identifier
44
+
45
+ if provider.key?(identifier)
46
+ provider[identifier]
47
+ elsif application.key?(identifier)
48
+ application[identifier]
49
+ end
50
+ end
51
+
52
+ def resolve_paired_view(action_class)
53
+ view_identifiers = application.config.actions.view_name_inferrer.(
54
+ action_name: action_class.name,
55
+ provider: provider
56
+ )
57
+
58
+ view_identifiers.detect { |identifier|
59
+ break provider[identifier] if provider.key?(identifier)
60
+ }
61
+ end
62
+
63
+ def configure_action(action_class)
64
+ action_class.config.settings.each do |setting|
65
+ application_value = application.config.actions.public_send(:"#{setting}")
66
+ action_class.config.public_send :"#{setting}=", application_value
67
+ end
68
+ end
69
+
70
+ def extend_behavior(action_class)
71
+ if application.config.actions.csrf_protection
72
+ require "hanami/action/csrf_protection"
73
+ action_class.include Hanami::Action::CSRFProtection
74
+ end
75
+
76
+ if application.config.actions.cookies.enabled?
77
+ require "hanami/action/cookies"
78
+ action_class.include Hanami::Action::Cookies
79
+ end
80
+ end
81
+
82
+ module InstanceMethods
83
+ attr_reader :view
84
+ attr_reader :view_context
85
+
86
+ protected
87
+
88
+ def handle(request, response)
89
+ if view
90
+ response.render view, **request.params
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def build_response(**options)
97
+ options = options.merge(view_options: method(:view_options))
98
+ super(**options)
99
+ end
100
+
101
+ def view_options(req, res)
102
+ {context: view_context&.with(**view_context_options(req, res))}.compact
103
+ end
104
+
105
+ def view_context_options(req, res)
106
+ {request: req, response: res}
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "application_configuration/cookies"
4
+ require_relative "application_configuration/sessions"
5
+ require_relative "configuration"
6
+ require_relative "view_name_inferrer"
7
+
8
+ module Hanami
9
+ class Action
10
+ class ApplicationConfiguration
11
+ include Dry::Configurable
12
+
13
+ setting(:cookies, {}) { |options| Cookies.new(options) }
14
+ setting(:sessions) { |storage, *options| Sessions.new(storage, *options) }
15
+ setting :csrf_protection
16
+
17
+ setting :name_inference_base, "actions"
18
+ setting :view_context_identifier, "view.context"
19
+ setting :view_name_inferrer, ViewNameInferrer
20
+ setting :view_name_inference_base, "views"
21
+
22
+ def initialize(*)
23
+ super
24
+
25
+ @base_configuration = Configuration.new
26
+
27
+ configure_defaults
28
+ end
29
+
30
+ def finalize!
31
+ # A nil value for `csrf_protection` means it has not been explicitly configured
32
+ # (neither true nor false), so we can default it to whether sessions are enabled
33
+ self.csrf_protection = sessions.enabled? if csrf_protection.nil?
34
+ end
35
+
36
+ # Returns the list of available settings
37
+ #
38
+ # @return [Set]
39
+ #
40
+ # @since 2.0.0
41
+ # @api private
42
+ def settings
43
+ base_configuration.settings + self.class.settings
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :base_configuration
49
+
50
+ # Apply defaults for base configuration settings
51
+ def configure_defaults
52
+ self.default_request_format = :html
53
+ self.default_response_format = :html
54
+
55
+ self.default_headers = {
56
+ "X-Frame-Options" => "DENY",
57
+ "X-Content-Type-Options" => "nosniff",
58
+ "X-XSS-Protection" => "1; mode=block",
59
+ "Content-Security-Policy" => \
60
+ "base-uri 'self'; " \
61
+ "child-src 'self'; " \
62
+ "connect-src 'self'; " \
63
+ "default-src 'none'; " \
64
+ "font-src 'self'; " \
65
+ "form-action 'self'; " \
66
+ "frame-ancestors 'self'; " \
67
+ "frame-src 'self'; " \
68
+ "img-src 'self' https: data:; " \
69
+ "media-src 'self'; " \
70
+ "object-src 'none'; " \
71
+ "plugin-types application/pdf; " \
72
+ "script-src 'self'; " \
73
+ "style-src 'self' 'unsafe-inline' https:"
74
+ }
75
+ end
76
+
77
+ def method_missing(name, *args, &block)
78
+ if config.respond_to?(name)
79
+ config.public_send(name, *args, &block)
80
+ elsif base_configuration.respond_to?(name)
81
+ base_configuration.public_send(name, *args, &block)
82
+ else
83
+ super
84
+ end
85
+ end
86
+
87
+ def respond_to_missing?(name, _incude_all = false)
88
+ config.respond_to?(name) || base_configuration.respond_to?(name) || super
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Action
5
+ class ApplicationConfiguration
6
+ # Wrapper for application-level configuration of HTTP cookies for Hanami actions.
7
+ # This decorates the hash of cookie options that is otherwise directly configurable
8
+ # on actions, and adds the `enabled?` method to allow `ApplicationAction` to
9
+ # determine whether to include the `Action::Cookies` module.
10
+ #
11
+ # @since 2.0.0
12
+ class Cookies
13
+ attr_reader :options
14
+
15
+ def initialize(options)
16
+ @options = options
17
+ end
18
+
19
+ def enabled?
20
+ !options.nil?
21
+ end
22
+
23
+ def to_h
24
+ options.to_h
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/constants"
4
+ require "hanami/utils/string"
5
+ require "hanami/utils/class"
6
+
7
+ module Hanami
8
+ class Action
9
+ class ApplicationConfiguration
10
+ # Configuration for HTTP sessions in Hanami actions
11
+ #
12
+ # @since 2.0.0
13
+ class Sessions
14
+ attr_reader :storage, :options
15
+
16
+ def initialize(storage = nil, *options)
17
+ @storage = storage
18
+ @options = options
19
+ end
20
+
21
+ def enabled?
22
+ !storage.nil?
23
+ end
24
+
25
+ def middleware
26
+ return [] if !enabled?
27
+
28
+ [[storage_middleware, options]]
29
+ end
30
+
31
+ private
32
+
33
+ def storage_middleware
34
+ require_storage
35
+
36
+ name = Utils::String.classify(storage)
37
+ Utils::Class.load!(name, ::Rack::Session)
38
+ end
39
+
40
+ def require_storage
41
+ require "rack/session/#{storage}"
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -69,7 +69,10 @@ module Hanami
69
69
  # @since 0.3.0
70
70
  # @api private
71
71
  def fresh?
72
- !Hanami::Utils::Blank.blank?(modified_since) && Time.httpdate(modified_since).to_i >= @value.to_time.to_i
72
+ return false if Hanami::Utils::Blank.blank?(modified_since)
73
+ return false if Hanami::Utils::Blank.blank?(@value)
74
+
75
+ Time.httpdate(modified_since).to_i >= @value.to_time.to_i
73
76
  end
74
77
 
75
78
  # @since 0.3.0
@@ -0,0 +1,430 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/configurable"
4
+ require "hanami/utils/kernel"
5
+ require "pathname"
6
+ require_relative "mime"
7
+
8
+ module Hanami
9
+ class Action
10
+ class Configuration
11
+ include Dry::Configurable
12
+
13
+ # Initialize the Configuration
14
+ #
15
+ # @yield [config] the configuration object
16
+ #
17
+ # @return [Configuration]
18
+ #
19
+ # @since 2.0.0
20
+ # @api private
21
+ def initialize(*)
22
+ super
23
+ yield self if block_given?
24
+ end
25
+
26
+ # Returns the list of available settings
27
+ #
28
+ # @return [Set]
29
+ #
30
+ # @since 2.0.0
31
+ # @api private
32
+ def settings
33
+ self.class.settings
34
+ end
35
+
36
+ # @!method handled_exceptions=(exceptions)
37
+ #
38
+ # Specifies how to handle exceptions with an HTTP status
39
+ #
40
+ # Raised exceptions will return the corresponding HTTP status
41
+ #
42
+ # @param exceptions [Hash{Exception=>Integer}] exception classes as
43
+ # keys and HTTP statuses as values
44
+ #
45
+ # @return [void]
46
+ #
47
+ # @since 0.2.0
48
+ #
49
+ # @example
50
+ # configuration.handled_exceptions = {ArgumentError => 400}
51
+ #
52
+ # @!method handled_exceptions
53
+ #
54
+ # Returns the configured handled exceptions
55
+ #
56
+ # @return [Hash{Exception=>Integer}]
57
+ #
58
+ # @see handled_exceptions=
59
+ #
60
+ # @since 0.2.0
61
+ setting :handled_exceptions, {}
62
+
63
+ # Specifies how to handle exceptions with an HTTP status
64
+ #
65
+ # Raised exceptions will return the corresponding HTTP status
66
+ #
67
+ # The specified exceptions will be merged with any previously configured
68
+ # exceptions
69
+ #
70
+ # @param exceptions [Hash{Exception=>Integer}] exception classes as keys
71
+ # and HTTP statuses as values
72
+ #
73
+ # @return [void]
74
+ #
75
+ # @since 0.2.0
76
+ #
77
+ # @see handled_exceptions=
78
+ #
79
+ # @example
80
+ # configuration.handle_exceptions(ArgumentError => 400}
81
+ def handle_exception(exceptions)
82
+ self.handled_exceptions = handled_exceptions
83
+ .merge(exceptions)
84
+ .sort { |(ex1, _), (ex2, _)| ex1.ancestors.include?(ex2) ? -1 : 1 }
85
+ .to_h
86
+ end
87
+
88
+ # Default MIME type to format mapping
89
+ #
90
+ # @since 0.2.0
91
+ # @api private
92
+ DEFAULT_FORMATS = {
93
+ 'application/octet-stream' => :all,
94
+ '*/*' => :all,
95
+ 'text/html' => :html
96
+ }.freeze
97
+
98
+ # @!method formats=(formats)
99
+ #
100
+ # Specifies the MIME type to format mapping
101
+ #
102
+ # @param formats [Hash{String=>Symbol}] MIME type strings as keys and
103
+ # format symbols as values
104
+ #
105
+ # @return [void]
106
+ #
107
+ # @since 0.2.0
108
+ #
109
+ # @see format
110
+ # @see Hanami::Action::Mime
111
+ #
112
+ # @example
113
+ # configuration.formats = {"text/html" => :html}
114
+ #
115
+ # @!method formats
116
+ #
117
+ # Returns the configured MIME type to format mapping
118
+ #
119
+ # @return [Symbol,nil] the corresponding format, if present
120
+ #
121
+ # @see format
122
+ # @see formats=
123
+ #
124
+ # @since 0.2.0
125
+ setting :formats, DEFAULT_FORMATS.dup
126
+
127
+ # Registers a MIME type to format mapping
128
+ #
129
+ # @param hash [Hash{Symbol=>String}] format symbols as keys and the MIME
130
+ # type strings must as values
131
+ #
132
+ # @return [void]
133
+ #
134
+ # @since 0.2.0
135
+ #
136
+ # @see Hanami::Action::Mime
137
+ #
138
+ # @example configuration.format html: "text/html"
139
+ def format(hash)
140
+ symbol, mime_type = *Utils::Kernel.Array(hash)
141
+ formats[Utils::Kernel.String(mime_type)] = Utils::Kernel.Symbol(symbol)
142
+ end
143
+
144
+ # Returns the configured format for the given MIME type
145
+ #
146
+ # @param mime_type [#to_s,#to_str] A mime type
147
+ #
148
+ # @return [Symbol,nil] the corresponding format, nil if not found
149
+ #
150
+ # @see format
151
+ #
152
+ # @since 0.2.0
153
+ # @api private
154
+ def format_for(mime_type)
155
+ formats[mime_type]
156
+ end
157
+
158
+ # Returns the configured format's MIME types
159
+ #
160
+ # @return [Array<String>] the format's MIME types
161
+ #
162
+ # @see formats=
163
+ # @see format
164
+ #
165
+ # @since 0.8.0
166
+ #
167
+ # @api private
168
+ def mime_types
169
+ # FIXME: this isn't efficient. speed it up!
170
+ ((formats.keys - DEFAULT_FORMATS.keys) +
171
+ Hanami::Action::Mime::TYPES.values).freeze
172
+ end
173
+
174
+ # Returns a MIME type for the given format
175
+ #
176
+ # @param format [#to_sym] a format
177
+ #
178
+ # @return [String,nil] the corresponding MIME type, if present
179
+ #
180
+ # @since 0.2.0
181
+ # @api private
182
+ def mime_type_for(format)
183
+ formats.key(format)
184
+ end
185
+
186
+ # @!method default_request_format=(format)
187
+ #
188
+ # Sets a format as default fallback for all the requests without a strict
189
+ # requirement for the MIME type.
190
+ #
191
+ # The given format must be coercible to a symbol, and be a valid MIME
192
+ # type alias. If it isn't, at runtime the framework will raise an
193
+ # `Hanami::Controller::UnknownFormatError`.
194
+ #
195
+ # By default, this value is nil.
196
+ #
197
+ # @param format [Symbol]
198
+ #
199
+ # @return [void]
200
+ #
201
+ # @since 0.5.0
202
+ #
203
+ # @see Hanami::Action::Mime
204
+ #
205
+ # @!method default_request_format
206
+ #
207
+ # Returns the configured default request format
208
+ #
209
+ # @return [Symbol] format
210
+ #
211
+ # @see default_request_format=
212
+ #
213
+ # @since 0.5.0
214
+ setting :default_request_format do |format|
215
+ Utils::Kernel.Symbol(format) unless format.nil?
216
+ end
217
+
218
+ # @!method default_response_format=(format)
219
+ #
220
+ # Sets a format to be used for all responses regardless of the request
221
+ # type.
222
+ #
223
+ # The given format must be coercible to a symbol, and be a valid MIME
224
+ # type alias. If it isn't, at the runtime the framework will raise an
225
+ # `Hanami::Controller::UnknownFormatError`.
226
+ #
227
+ # By default, this value is nil.
228
+ #
229
+ # @param format [Symbol]
230
+ #
231
+ # @return [void]
232
+ #
233
+ # @since 0.5.0
234
+ #
235
+ # @see Hanami::Action::Mime
236
+ #
237
+ # @!method default_response_format
238
+ #
239
+ # Returns the configured default response format
240
+ #
241
+ # @return [Symbol] format
242
+ #
243
+ # @see default_request_format=
244
+ #
245
+ # @since 0.5.0
246
+ setting :default_response_format do |format|
247
+ Utils::Kernel.Symbol(format) unless format.nil?
248
+ end
249
+
250
+ # @!method default_charset=(charset)
251
+ #
252
+ # Sets a charset (character set) as default fallback for all the requests
253
+ # without a strict requirement for the charset.
254
+ #
255
+ # By default, this value is nil.
256
+ #
257
+ # @param charset [String]
258
+ #
259
+ # @return [void]
260
+ #
261
+ # @since 0.3.0
262
+ #
263
+ # @see Hanami::Action::Mime
264
+ #
265
+ # @!method default_charset
266
+ #
267
+ # Returns the configured default charset.
268
+ #
269
+ # @return [String,nil] the charset, if present
270
+ #
271
+ # @see default_charset=
272
+ #
273
+ # @since 0.3.0
274
+ setting :default_charset
275
+
276
+ # @!method default_headers=(headers)
277
+ #
278
+ # Sets default headers for all responses.
279
+ #
280
+ # By default, this is an empty hash.
281
+ #
282
+ # @param headers [Hash{String=>String}] the headers
283
+ #
284
+ # @return [void]
285
+ #
286
+ # @since 0.4.0
287
+ #
288
+ # @see default_headers
289
+ #
290
+ # @example
291
+ # configuration.default_headers = {'X-Frame-Options' => 'DENY'}
292
+ #
293
+ # @!method default_headers
294
+ #
295
+ # Returns the configured headers
296
+ #
297
+ # @return [Hash{String=>String}] the headers
298
+ #
299
+ # @since 0.4.0
300
+ #
301
+ # @see default_headers=
302
+ setting :default_headers, {} do |headers|
303
+ headers.compact
304
+ end
305
+
306
+ # @!method cookies=(cookie_options)
307
+ #
308
+ # Sets default cookie options for all responses.
309
+ #
310
+ # By default this, is an empty hash.
311
+ #
312
+ # @param cookie_options [Hash{Symbol=>String}] the cookie options
313
+ #
314
+ # @return [void]
315
+ #
316
+ # @since 0.4.0
317
+ #
318
+ # @example
319
+ # configuration.cookies = {
320
+ # domain: 'hanamirb.org',
321
+ # path: '/controller',
322
+ # secure: true,
323
+ # httponly: true
324
+ # }
325
+ #
326
+ # @!method cookies
327
+ #
328
+ # Returns the configured cookie options
329
+ #
330
+ # @return [Hash{Symbol=>String}]
331
+ #
332
+ # @since 0.4.0
333
+ #
334
+ # @see cookies=
335
+ setting :cookies, {} do |cookie_options|
336
+ # Call `to_h` here to permit `ApplicationConfiguration::Cookies` object to be
337
+ # provided when application actions are configured
338
+ cookie_options.to_h.compact
339
+ end
340
+
341
+ # @!method root_directory=(dir)
342
+ #
343
+ # Sets the the for the public directory, which is used for file downloads.
344
+ # This must be an existent directory.
345
+ #
346
+ # Defaults to the current working directory.
347
+ #
348
+ # @param dir [String] the directory path
349
+ #
350
+ # @return [void]
351
+ #
352
+ # @since 1.0.0
353
+ #
354
+ # @api private
355
+ #
356
+ # @!method root_directory
357
+ #
358
+ # Returns the configured root directory
359
+ #
360
+ # @return [String] the directory path
361
+ #
362
+ # @see root_directory=
363
+ #
364
+ # @since 1.0.0
365
+ #
366
+ # @api private
367
+ setting :root_directory do |dir|
368
+ dir ||= Dir.pwd
369
+
370
+ Pathname(dir).realpath
371
+ end
372
+
373
+ # Default public directory
374
+ #
375
+ # This serves as the root directory for file downloads
376
+ #
377
+ # @since 1.0.0
378
+ #
379
+ # @api private
380
+ DEFAULT_PUBLIC_DIRECTORY = 'public'.freeze
381
+
382
+ # @!method public_directory=(directory)
383
+ #
384
+ # Sets the path to public directory. This directory is used for file downloads.
385
+ #
386
+ # This given directory will be appended onto the root directory.
387
+ #
388
+ # By default, the public directory is "public".
389
+ #
390
+ # @param directory [String] the public directory path
391
+ #
392
+ # @return [void]
393
+ #
394
+ # @see root_directory
395
+ # @see public_directory
396
+ setting :public_directory, DEFAULT_PUBLIC_DIRECTORY
397
+
398
+ # Returns the configured public directory, appended onto the root directory.
399
+ #
400
+ # @return [String] the fill directory path
401
+ #
402
+ # @example
403
+ # configuration.public_directory = "public"
404
+ #
405
+ # configuration.public_directory
406
+ # # => "/path/to/root/public"
407
+ #
408
+ # @see public_directory=
409
+ # @see root_directory=
410
+ def public_directory
411
+ # This must be a string, for Rack compatibility
412
+ root_directory.join(super).to_s
413
+ end
414
+
415
+ private
416
+
417
+ def method_missing(name, *args, &block)
418
+ if config.respond_to?(name)
419
+ config.public_send(name, *args, &block)
420
+ else
421
+ super
422
+ end
423
+ end
424
+
425
+ def respond_to_missing?(name, _incude_all = false)
426
+ config.respond_to?(name) || super
427
+ end
428
+ end
429
+ end
430
+ end