hanami-controller 2.0.0.alpha1 → 2.0.0.alpha2

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.
@@ -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