hanami-controller 2.0.0.rc1 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -68,260 +68,217 @@ module Hanami
68
68
  zip: "application/zip"
69
69
  }.freeze
70
70
 
71
- # @since 2.0.0
72
- # @api private
73
- def self.content_type_with_charset(content_type, charset)
74
- "#{content_type}; charset=#{charset}"
75
- end
71
+ ANY_TYPE = "*/*"
72
+
73
+ class << self
74
+ # Returns a format name for the given content type.
75
+ #
76
+ # The format name will come from the configured formats, if such a format is configured
77
+ # there, or instead from the default list of formats in `Mime::TYPES`.
78
+ #
79
+ # Returns nil if no matching format can be found.
80
+ #
81
+ # This is used to return the format name a {Response}.
82
+ #
83
+ # @example
84
+ # detect_format("application/jsonl charset=utf-8", config) # => :json
85
+ #
86
+ # @return [Symbol, nil]
87
+ #
88
+ # @see Response#format
89
+ # @see Action#finish
90
+ #
91
+ # @since 2.0.0
92
+ # @api private
93
+ def detect_format(content_type, config)
94
+ return if content_type.nil?
76
95
 
77
- # Use for setting Content-Type
78
- # If the request has the ACCEPT header it will try to return the best Content-Type based
79
- # on the content of the ACCEPT header taking in consideration the weights
80
- #
81
- # If no ACCEPT header it will check the default response_format, then the default request format and
82
- # lastly it will fallback to DEFAULT_CONTENT_TYPE
83
- #
84
- # @return [String]
85
- #
86
- # @since 2.0.0
87
- # @api private
88
- def self.content_type(config, request, accepted_mime_types)
89
- if request.accept_header?
90
- type = best_q_match(request.accept, accepted_mime_types)
91
- return type if type
96
+ ct = content_type.split(";").first
97
+ config.formats.format_for(ct) || TYPES.key(ct)
92
98
  end
93
99
 
94
- default_response_type(config) || default_content_type(config) || Action::DEFAULT_CONTENT_TYPE
95
- end
96
-
97
- # @since 2.0.0
98
- # @api private
99
- def self.charset(default_charset)
100
- default_charset || Action::DEFAULT_CHARSET
101
- end
102
-
103
- # @since 2.0.0
104
- # @api private
105
- def self.default_response_type(config)
106
- format_to_mime_type(config.default_response_format, config)
107
- end
108
-
109
- # @since 2.0.0
110
- # @api private
111
- def self.default_content_type(config)
112
- format_to_mime_type(config.default_request_format, config)
113
- end
114
-
115
- # @since 2.0.0
116
- # @api private
117
- def self.format_to_mime_type(format, config)
118
- return if format.nil?
119
-
120
- config.mime_type_for(format) ||
121
- TYPES.fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) }
122
- end
123
-
124
- # Transforms MIME Types to symbol
125
- # Used for setting the format of the response
126
- #
127
- # @see Hanami::Action::Mime#finish
128
- # @example
129
- # detect_format("text/html; charset=utf-8", config) #=> :html
130
- #
131
- # @return [Symbol, nil]
132
- #
133
- # @since 2.0.0
134
- # @api private
135
- def self.detect_format(content_type, config)
136
- return if content_type.nil?
137
-
138
- ct = content_type.split(";").first
139
- config.format_for(ct) || format_for(ct)
140
- end
141
-
142
- # @since 2.0.0
143
- # @api private
144
- def self.format_for(content_type)
145
- TYPES.key(content_type)
146
- end
147
-
148
- # @since 2.0.0
149
- # @api private
150
- def self.detect_format_and_content_type(value, config)
151
- case value
152
- when Symbol
153
- [value, format_to_mime_type(value, config)]
154
- when String
155
- [detect_format(value, config), value]
156
- else
157
- raise UnknownFormatError.new(value)
100
+ # Returns a format name and content type pair for a given format name or content type
101
+ # string.
102
+ #
103
+ # @example
104
+ # detect_format_and_content_type(:json, config)
105
+ # # => [:json, "application/json"]
106
+ #
107
+ # detect_format_and_content_type("application/json", config)
108
+ # # => [:json, "application/json"]
109
+ #
110
+ # @example Unknown format name
111
+ # detect_format_and_content_type(:unknown, config)
112
+ # # raises Hanami::Action::UnknownFormatError
113
+ #
114
+ # @example Unknown content type
115
+ # detect_format_and_content_type("application/unknown", config)
116
+ # # => [nil, "application/unknown"]
117
+ #
118
+ # @return [Array<(Symbol, String)>]
119
+ #
120
+ # @raise [Hanami::Action::UnknownFormatError] if an unknown format name is given
121
+ #
122
+ # @since 2.0.0
123
+ # @api private
124
+ def detect_format_and_content_type(value, config)
125
+ case value
126
+ when Symbol
127
+ [value, format_to_mime_type(value, config)]
128
+ when String
129
+ [detect_format(value, config), value]
130
+ else
131
+ raise UnknownFormatError.new(value)
132
+ end
158
133
  end
159
- end
160
134
 
161
- # Transforms symbols to MIME Types
162
- # @example
163
- # restrict_mime_types(config, [:json]) #=> ["application/json"]
164
- #
165
- # @return [Array<String>, nil]
166
- #
167
- # @raise [Hanami::Action::UnknownFormatError] if the format is invalid
168
- #
169
- # @since 2.0.0
170
- # @api private
171
- def self.restrict_mime_types(config)
172
- return if config.accepted_formats.empty?
173
-
174
- mime_types = config.accepted_formats.map do |format|
175
- format_to_mime_type(format, config)
135
+ # Returns a string combining the given content type and charset, intended for setting as a
136
+ # `Content-Type` header.
137
+ #
138
+ # @example
139
+ # Mime.content_type_with_charset("application/json", "utf-8")
140
+ # # => "application/json; charset=utf-8"
141
+ #
142
+ # @param content_type [String]
143
+ # @param charset [String]
144
+ #
145
+ # @return [String]
146
+ #
147
+ # @since 2.0.0
148
+ # @api private
149
+ def content_type_with_charset(content_type, charset)
150
+ "#{content_type}; charset=#{charset}"
176
151
  end
177
152
 
178
- accepted_mime_types = mime_types & config.mime_types
179
-
180
- return if accepted_mime_types.empty?
181
-
182
- accepted_mime_types
183
- end
184
-
185
- # Yields if an action is configured with `accepted_formats`, the request has an `Accept`
186
- # header, and none of the Accept types matches the accepted formats. The given block is
187
- # expected to halt the request handling.
188
- #
189
- # If any of these conditions are not met, then the request is acceptable and the method
190
- # returns without yielding.
191
- #
192
- # @see Action#enforce_accepted_mime_types
193
- # @see Action.accept
194
- # @see Config#accepted_formats
195
- #
196
- # @since 2.0.0
197
- # @api private
198
- def self.enforce_accept(request, config)
199
- return unless request.accept_header?
200
-
201
- accept_types = ::Rack::Utils.q_values(request.accept).map(&:first)
202
- return if accept_types.any? { |mime_type| accepted_mime_type?(mime_type, config) }
153
+ # Returns a string combining a MIME type and charset, intended for setting as the
154
+ # `Content-Type` header for the response to the given request.
155
+ #
156
+ # This uses the request's `Accept` header (if present) along with the configured formats to
157
+ # determine the best content type to return.
158
+ #
159
+ # @return [String]
160
+ #
161
+ # @see Action#call
162
+ #
163
+ # @since 2.0.0
164
+ # @api private
165
+ def response_content_type_with_charset(request, config)
166
+ content_type_with_charset(
167
+ response_content_type(request, config),
168
+ config.default_charset || Action::DEFAULT_CHARSET
169
+ )
170
+ end
203
171
 
204
- yield
205
- end
172
+ # Patched version of <tt>Rack::Utils.best_q_match</tt>.
173
+ #
174
+ # @since 2.0.0
175
+ # @api private
176
+ #
177
+ # @see http://www.rubydoc.info/gems/rack/Rack/Utils#best_q_match-class_method
178
+ # @see https://github.com/rack/rack/pull/659
179
+ # @see https://github.com/hanami/controller/issues/59
180
+ # @see https://github.com/hanami/controller/issues/104
181
+ # @see https://github.com/hanami/controller/issues/275
182
+ def best_q_match(q_value_header, available_mimes = TYPES.values)
183
+ ::Rack::Utils.q_values(q_value_header).each_with_index.map { |(req_mime, quality), index|
184
+ match = available_mimes.find { |am| ::Rack::Mime.match?(am, req_mime) }
185
+ next unless match
186
+
187
+ RequestMimeWeight.new(req_mime, quality, index, match)
188
+ }.compact.max&.format
189
+ end
206
190
 
207
- # Yields if an action is configured with `accepted_formats`, the request has a `Content-Type`
208
- # header (or a `default_requst_format` is configured), and the content type does not match the
209
- # accepted formats. The given block is expected to halt the request handling.
210
- #
211
- # If any of these conditions are not met, then the request is acceptable and the method
212
- # returns without yielding.
213
- #
214
- # @see Action#enforce_accepted_mime_types
215
- # @see Action.accept
216
- # @see Config#accepted_formats
217
- #
218
- # @since 2.0.0
219
- # @api private
220
- def self.enforce_content_type(request, config)
221
- content_type = request.content_type || default_content_type(config)
191
+ # Yields if an action is configured with `formats`, the request has an `Accept` header, an
192
+ # none of the Accept types matches the accepted formats. The given block is expected to halt
193
+ # the request handling.
194
+ #
195
+ # If any of these conditions are not met, then the request is acceptable and the method
196
+ # returns without yielding.
197
+ #
198
+ # @see Action#enforce_accepted_mime_types
199
+ # @see Config#formats
200
+ #
201
+ # @since 2.0.0
202
+ # @api private
203
+ def enforce_accept(request, config)
204
+ return unless request.accept_header?
222
205
 
223
- return if content_type.nil?
206
+ accept_types = ::Rack::Utils.q_values(request.accept).map(&:first)
207
+ return if accept_types.any? { |mime_type| accepted_mime_type?(mime_type, config) }
224
208
 
225
- return if accepted_mime_type?(content_type, config)
209
+ yield
210
+ end
226
211
 
227
- yield
228
- end
212
+ # Yields if an action is configured with `formats`, the request has a `Content-Type` header
213
+ # (or a `default_requst_format` is configured), and the content type does not match the
214
+ # accepted formats. The given block is expected to halt the request handling.
215
+ #
216
+ # If any of these conditions are not met, then the request is acceptable and the method
217
+ # returns without yielding.
218
+ #
219
+ # @see Action#enforce_accepted_mime_types
220
+ # @see Config#formats
221
+ #
222
+ # @since 2.0.0
223
+ # @api private
224
+ def enforce_content_type(request, config)
225
+ content_type = request.content_type
229
226
 
230
- # @since 2.0.0
231
- # @api private
232
- def self.accepted_mime_type?(mime_type, config)
233
- config.accepted_mime_types.any? { |accepted_mime_type|
234
- ::Rack::Mime.match?(accepted_mime_type, mime_type)
235
- }
236
- end
227
+ return if content_type.nil?
237
228
 
238
- # Use for setting the content_type and charset if the response
239
- #
240
- # @return [String]
241
- #
242
- # @since 2.0.0
243
- # @api private
244
- def self.calculate_content_type_with_charset(config, request, accepted_mime_types)
245
- charset = self.charset(config.default_charset)
246
- content_type = self.content_type(config, request, accepted_mime_types)
247
- content_type_with_charset(content_type, charset)
248
- end
229
+ return if accepted_mime_type?(content_type, config)
249
230
 
250
- # Patched version of <tt>Rack::Utils.best_q_match</tt>.
251
- #
252
- # @since 2.0.0
253
- # @api private
254
- #
255
- # @see http://www.rubydoc.info/gems/rack/Rack/Utils#best_q_match-class_method
256
- # @see https://github.com/rack/rack/pull/659
257
- # @see https://github.com/hanami/controller/issues/59
258
- # @see https://github.com/hanami/controller/issues/104
259
- # @see https://github.com/hanami/controller/issues/275
260
- def self.best_q_match(q_value_header, available_mimes = TYPES.values)
261
- ::Rack::Utils.q_values(q_value_header).each_with_index.map do |(req_mime, quality), index|
262
- match = available_mimes.find { |am| ::Rack::Mime.match?(am, req_mime) }
263
- next unless match
231
+ yield
232
+ end
264
233
 
265
- RequestMimeWeight.new(req_mime, quality, index, match)
266
- end.compact.max&.format
267
- end
234
+ private
268
235
 
269
- # @since 1.0.1
270
- # @api private
271
- class RequestMimeWeight
272
236
  # @since 2.0.0
273
237
  # @api private
274
- MIME_SEPARATOR = "/"
275
- private_constant :MIME_SEPARATOR
238
+ def accepted_mime_type?(mime_type, config)
239
+ accepted_mime_types(config).any? { |accepted_mime_type|
240
+ ::Rack::Mime.match?(accepted_mime_type, mime_type)
241
+ }
242
+ end
276
243
 
277
244
  # @since 2.0.0
278
245
  # @api private
279
- MIME_WILDCARD = "*"
280
- private_constant :MIME_WILDCARD
281
-
282
- include Comparable
283
-
284
- # @since 1.0.1
285
- # @api private
286
- attr_reader :quality
246
+ def accepted_mime_types(config)
247
+ return [ANY_TYPE] if config.formats.empty?
287
248
 
288
- # @since 1.0.1
289
- # @api private
290
- attr_reader :index
249
+ config.formats.map { |format| format_to_mime_types(format, config) }.flatten(1)
250
+ end
291
251
 
292
- # @since 1.0.1
252
+ # @since 2.0.0
293
253
  # @api private
294
- attr_reader :mime
254
+ def response_content_type(request, config)
255
+ if request.accept_header?
256
+ all_mime_types = TYPES.values + config.formats.mapping.keys
257
+ content_type = best_q_match(request.accept, all_mime_types)
295
258
 
296
- # @since 1.0.1
297
- # @api private
298
- attr_reader :format
259
+ return content_type if content_type
260
+ end
299
261
 
300
- # @since 1.0.1
301
- # @api private
302
- attr_reader :priority
262
+ if config.formats.default
263
+ return format_to_mime_type(config.formats.default, config)
264
+ end
303
265
 
304
- # @since 1.0.1
305
- # @api private
306
- def initialize(mime, quality, index, format = mime)
307
- @quality, @index, @format = quality, index, format
308
- calculate_priority(mime)
266
+ Action::DEFAULT_CONTENT_TYPE
309
267
  end
310
268
 
311
- # @since 1.0.1
269
+ # @since 2.0.0
312
270
  # @api private
313
- def <=>(other)
314
- return priority <=> other.priority unless priority == other.priority
315
-
316
- other.index <=> index
271
+ def format_to_mime_type(format, config)
272
+ config.formats.mime_type_for(format) ||
273
+ TYPES.fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) }
317
274
  end
318
275
 
319
- private
320
-
321
- # @since 1.0.1
276
+ # @since 2.0.0
322
277
  # @api private
323
- def calculate_priority(mime)
324
- @priority ||= (mime.split(MIME_SEPARATOR, 2).count(MIME_WILDCARD) * -10) + quality
278
+ def format_to_mime_types(format, config)
279
+ config.formats.mime_types_for(format).tap { |types|
280
+ types << TYPES[format] if TYPES.key?(format)
281
+ }
325
282
  end
326
283
  end
327
284
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "hanami/action/base_params"
4
3
  require "hanami/validations/form"
5
4
 
6
5
  module Hanami
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "hanami/action/flash"
4
3
  require "rack/mime"
5
4
  require "rack/request"
6
5
  require "rack/utils"
@@ -3,11 +3,6 @@
3
3
  require "rack"
4
4
  require "rack/response"
5
5
  require "hanami/utils/kernel"
6
- require "hanami/action/halt"
7
- require "hanami/action/cookie_jar"
8
- require "hanami/action/cache/cache_control"
9
- require "hanami/action/cache/expires"
10
- require "hanami/action/cache/conditional_get"
11
6
  require_relative "errors"
12
7
 
13
8
  module Hanami
@@ -44,10 +39,10 @@ module Hanami
44
39
  # @since 2.0.0
45
40
  # @api private
46
41
  def self.build(status, env)
47
- new(config: nil, content_type: Mime.best_q_match(env[Action::HTTP_ACCEPT]), env: env).tap do |r|
42
+ new(config: Action.config.dup, content_type: Mime.best_q_match(env[Action::HTTP_ACCEPT]), env: env).tap do |r|
48
43
  r.status = status
49
44
  r.body = Http::Status.message_for(status)
50
- r.set_format(Mime.format_for(r.content_type))
45
+ r.set_format(Mime.detect_format(r.content_type), config)
51
46
  end
52
47
  end
53
48
 
@@ -111,9 +106,12 @@ module Hanami
111
106
 
112
107
  # Sets the format and associated content type for the response.
113
108
  #
114
- # Either a format name (`:json`) or a content type string (`"application/json"`) may be given.
115
- # In either case, the format or content type will be derived from the given value, and both
116
- # will be set.
109
+ # Either a format name (`:json`) or a MIME type (`"application/json"`) may be given. In either
110
+ # case, the format or content type will be derived from the given value, and both will be set.
111
+ #
112
+ # Providing an unknown format name will raise an {Hanami::Action::UnknownFormatError}.
113
+ #
114
+ # Providing an unknown MIME type will set the content type and set the format as nil.
117
115
  #
118
116
  # @example Assigning via a format name symbol
119
117
  # response.format = :json
@@ -127,6 +125,8 @@ module Hanami
127
125
  #
128
126
  # @param value [Symbol, String] the format name or content type
129
127
  #
128
+ # @raise [Hanami::Action::UnknownFormatError] if an unknown format name is given
129
+ #
130
130
  # @see Config#formats
131
131
  #
132
132
  # @since 2.0.0
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "hanami/action/flash"
4
-
5
3
  module Hanami
6
4
  class Action
7
5
  # Session support for actions.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "hanami/action/params"
3
+ require_relative "params"
4
4
 
5
5
  module Hanami
6
6
  class Action
data/lib/hanami/action.rb CHANGED
@@ -1,29 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- begin
4
- require "dry/core"
5
- require "dry/types"
6
- require "hanami/validations"
7
- require "hanami/action/validatable"
8
- rescue LoadError # rubocop:disable Lint/SuppressedException
9
- end
10
-
11
3
  require "dry/configurable"
12
- require "hanami/utils/callbacks"
13
4
  require "hanami/utils"
14
- require "hanami/utils/string"
5
+ require "hanami/utils/callbacks"
15
6
  require "hanami/utils/kernel"
7
+ require "hanami/utils/string"
16
8
  require "rack"
17
9
  require "rack/utils"
10
+ require "zeitwerk"
18
11
 
19
- require_relative "action/config"
20
12
  require_relative "action/constants"
21
- require_relative "action/base_params"
22
- require_relative "action/halt"
23
- require_relative "action/mime"
24
- require_relative "action/rack/file"
25
- require_relative "action/request"
26
- require_relative "action/response"
27
13
  require_relative "action/errors"
28
14
 
29
15
  module Hanami
@@ -42,18 +28,37 @@ module Hanami
42
28
  #
43
29
  # @api public
44
30
  class Action
31
+ # @since 2.0.0
32
+ # @api private
33
+ def self.gem_loader
34
+ @gem_loader ||= Zeitwerk::Loader.new.tap do |loader|
35
+ root = File.expand_path("..", __dir__)
36
+ loader.tag = "hanami-controller"
37
+ loader.inflector = Zeitwerk::GemInflector.new("#{root}/hanami-controller.rb")
38
+ loader.push_dir(root)
39
+ loader.ignore(
40
+ "#{root}/hanami-controller.rb",
41
+ "#{root}/hanami/controller/version.rb",
42
+ "#{root}/hanami/action/{constants,errors,params,validatable}.rb"
43
+ )
44
+ loader.inflector.inflect("csrf_protection" => "CSRFProtection")
45
+ end
46
+ end
47
+
48
+ gem_loader.setup
49
+
50
+ # Make conditional requires after Zeitwerk setup so any internal autoloading works as expected
51
+ begin
52
+ require "hanami/validations"
53
+ require_relative "action/validatable"
54
+ rescue LoadError # rubocop:disable Lint/SuppressedException
55
+ end
56
+
45
57
  extend Dry::Configurable(config_class: Config)
46
58
 
47
59
  # See {Config} for individual setting accessor API docs
48
60
  setting :handled_exceptions, default: {}
49
- setting :formats, default: Config::DEFAULT_FORMATS
50
- setting :default_request_format, constructor: -> (format) {
51
- Utils::Kernel.Symbol(format) unless format.nil?
52
- }
53
- setting :default_response_format, constructor: -> (format) {
54
- Utils::Kernel.Symbol(format) unless format.nil?
55
- }
56
- setting :accepted_formats, default: []
61
+ setting :formats, default: Config::Formats.new, mutable: true
57
62
  setting :default_charset
58
63
  setting :default_headers, default: {}, constructor: -> (headers) { headers.compact }
59
64
  setting :cookies, default: {}, constructor: -> (cookie_options) {
@@ -65,8 +70,8 @@ module Hanami
65
70
  Pathname(File.expand_path(dir || Dir.pwd)).realpath
66
71
  }
67
72
  setting :public_directory, default: Config::DEFAULT_PUBLIC_DIRECTORY
68
- setting :before_callbacks, default: Utils::Callbacks::Chain.new, cloneable: true
69
- setting :after_callbacks, default: Utils::Callbacks::Chain.new, cloneable: true
73
+ setting :before_callbacks, default: Utils::Callbacks::Chain.new, mutable: true
74
+ setting :after_callbacks, default: Utils::Callbacks::Chain.new, mutable: true
70
75
 
71
76
  # @!scope class
72
77
 
@@ -75,7 +80,7 @@ module Hanami
75
80
  #
76
81
  # @example Access inside class body
77
82
  # class Show < Hanami::Action
78
- # config.default_response_format = :json
83
+ # config.format :json
79
84
  # end
80
85
  #
81
86
  # @return [Config]
@@ -266,34 +271,12 @@ module Hanami
266
271
  config.after_callbacks.prepend(...)
267
272
  end
268
273
 
269
- # Restrict the access to the specified mime type symbols.
270
- #
271
- # @param formats[Array<Symbol>] one or more symbols representing mime type(s)
272
- #
273
- # @raise [Hanami::Action::UnknownFormatError] if the symbol cannot
274
- # be converted into a mime type
275
- #
276
- # @since 0.1.0
277
- #
278
274
  # @see Config#format
279
275
  #
280
- # @example
281
- # require "hanami/controller"
282
- #
283
- # class Show < Hanami::Action
284
- # accept :html, :json
285
- #
286
- # def handle(req, res)
287
- # # ...
288
- # end
289
- # end
290
- #
291
- # # When called with "*/*" => 200
292
- # # When called with "text/html" => 200
293
- # # When called with "application/json" => 200
294
- # # When called with "application/xml" => 415
295
- def self.accept(*formats)
296
- config.accepted_formats = formats
276
+ # @since 2.0.0
277
+ # @api public
278
+ def self.format(...)
279
+ config.format(...)
297
280
  end
298
281
 
299
282
  # @see Config#handle_exception
@@ -331,7 +314,7 @@ module Hanami
331
314
  response = build_response(
332
315
  request: request,
333
316
  config: config,
334
- content_type: Mime.calculate_content_type_with_charset(config, request, config.accepted_mime_types),
317
+ content_type: Mime.response_content_type_with_charset(request, config),
335
318
  env: env,
336
319
  headers: config.default_headers,
337
320
  sessions_enabled: sessions_enabled?
@@ -425,7 +408,7 @@ module Hanami
425
408
  # @since 2.0.0
426
409
  # @api private
427
410
  def enforce_accepted_mime_types(request)
428
- return if config.accepted_formats.empty?
411
+ return if config.formats.empty?
429
412
 
430
413
  Mime.enforce_accept(request, config) { return halt 406 }
431
414
  Mime.enforce_content_type(request, config) { return halt 415 }