hanami-controller 2.0.0.rc1 → 2.0.0

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