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,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The Hanami::Action::Flash implementation is derived from Roda's FlashHash, also released under the
4
+ # MIT Licence:
5
+ #
6
+ # Copyright (c) 2014-2020 Jeremy Evans
7
+ # Copyright (c) 2010-2014 Michel Martens, Damian Janowski and Cyril David
8
+ # Copyright (c) 2008-2009 Christian Neukirchen
9
+
10
+ module Hanami
11
+ class Action
12
+ # A container to transport data with the HTTP session, with a lifespan of just one HTTP request
13
+ # or redirect.
14
+ #
15
+ # Behaves like a hash, returning entries for the current request, except for {#[]=}, which
16
+ # updates the hash for the next request.
17
+ #
18
+ # @since 0.3.0
19
+ # @api public
20
+ class Flash
21
+ # @since 2.0.0
22
+ # @api private
23
+ KEY = "_flash"
24
+
25
+ # @return [Hash] The flash hash for the next request, written to by {#[]=}.
26
+ #
27
+ # @see #[]=
28
+ #
29
+ # @since 2.0.0
30
+ # @api public
31
+ attr_reader :next
32
+
33
+ # Returns a new flash object.
34
+ #
35
+ # @param hash [Hash, nil] the flash hash for the current request; `nil` will become an empty hash.
36
+ #
37
+ # @since 0.3.0
38
+ # @api public
39
+ def initialize(hash = {})
40
+ @flash = hash || {}
41
+ @next = {}
42
+ end
43
+
44
+ # Returns the flash hash for the current request.
45
+ #
46
+ # @return [Hash] the flash hash for the current request
47
+ #
48
+ # @since 2.0.0
49
+ # @api public
50
+ def now
51
+ @flash
52
+ end
53
+
54
+ # Returns the value for the given key in the current hash.
55
+ #
56
+ # @param key [Object] the key
57
+ #
58
+ # @return [Object, nil] the value
59
+ #
60
+ # @since 0.3.0
61
+ # @api public
62
+ def [](key)
63
+ @flash[key]
64
+ end
65
+
66
+ # Updates the next hash with the given key and value.
67
+ #
68
+ # @param key [Object] the key
69
+ # @param value [Object] the value
70
+ #
71
+ # @since 0.3.0
72
+ # @api public
73
+ def []=(key, value)
74
+ @next[key] = value
75
+ end
76
+
77
+ # Calls the given block once for each element in the current hash.
78
+ #
79
+ # @yieldparam element [Array<(Object, Object)>] array containing the key and value from the
80
+ # hash
81
+ #
82
+ # @return [now]
83
+ #
84
+ # @since 1.2.0
85
+ # @api public
86
+ def each(&block)
87
+ @flash.each(&block)
88
+ end
89
+
90
+ # Returns an array of objects returned by the block, called once for each element in the
91
+ # current hash.
92
+ #
93
+ # @yieldparam element [Array<(Object, Object)>] array containing the key and value from the
94
+ # hash
95
+ #
96
+ # @return [Array]
97
+ #
98
+ # @since 1.2.0
99
+ # @api public
100
+ def map(&block)
101
+ @flash.map(&block)
102
+ end
103
+
104
+ # Returns `true` if the current hash contains no elements.
105
+ #
106
+ # @return [Boolean]
107
+ #
108
+ # @since 0.3.0
109
+ # @api public
110
+ def empty?
111
+ @flash.empty?
112
+ end
113
+
114
+ # Returns `true` if the given key is present in the current hash.
115
+ #
116
+ # @return [Boolean]
117
+ #
118
+ # @since 2.0.0
119
+ # @api public
120
+ def key?(key)
121
+ @flash.key?(key)
122
+ end
123
+
124
+ # Removes entries from the next hash.
125
+ #
126
+ # @overload discard(key)
127
+ # Removes the given key from the next hash
128
+ #
129
+ # @param key [Object] key to discard
130
+ #
131
+ # @overload discard
132
+ # Clears the next hash
133
+ #
134
+ # @since 2.0.0
135
+ # @api public
136
+ def discard(key = (no_arg = true))
137
+ if no_arg
138
+ @next.clear
139
+ else
140
+ @next.delete(key)
141
+ end
142
+ end
143
+
144
+ # Copies entries from the current hash to the next hash
145
+ #
146
+ # @overload keep(key)
147
+ # Copies the entry for the given key from the current hash to the next
148
+ # hash
149
+ #
150
+ # @param key [Object] key to copy
151
+ #
152
+ # @overload keep
153
+ # Copies all entries from the current hash to the next hash
154
+ #
155
+ # @since 2.0.0
156
+ # @api public
157
+ def keep(key = (no_arg = true))
158
+ if no_arg
159
+ @next.merge!(@flash)
160
+ else
161
+ self[key] = self[key]
162
+ end
163
+ end
164
+
165
+ # Replaces the current hash with the next hash and clears the next hash
166
+ #
167
+ # @since 2.0.0
168
+ # @api public
169
+ def sweep
170
+ @flash = @next.dup
171
+ @next.clear
172
+ self
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hanami/http/status"
4
+
5
+ module Hanami
6
+ class Action
7
+ # @api private
8
+ # @since 2.0.0
9
+ module Halt
10
+ # @api private
11
+ # @since 2.0.0
12
+ def self.call(status, body = nil)
13
+ code, message = Http::Status.for_code(status)
14
+ throw :halt, [code, body || message]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Action
5
+ module Mime
6
+ # @since 1.0.1
7
+ # @api private
8
+ class RequestMimeWeight
9
+ # @since 2.0.0
10
+ # @api private
11
+ MIME_SEPARATOR = "/"
12
+ private_constant :MIME_SEPARATOR
13
+
14
+ # @since 2.0.0
15
+ # @api private
16
+ MIME_WILDCARD = "*"
17
+ private_constant :MIME_WILDCARD
18
+
19
+ include Comparable
20
+
21
+ # @since 1.0.1
22
+ # @api private
23
+ attr_reader :quality
24
+
25
+ # @since 1.0.1
26
+ # @api private
27
+ attr_reader :index
28
+
29
+ # @since 1.0.1
30
+ # @api private
31
+ attr_reader :mime
32
+
33
+ # @since 1.0.1
34
+ # @api private
35
+ attr_reader :format
36
+
37
+ # @since 1.0.1
38
+ # @api private
39
+ attr_reader :priority
40
+
41
+ # @since 1.0.1
42
+ # @api private
43
+ def initialize(mime, quality, index, format = mime)
44
+ @quality, @index, @format = quality, index, format
45
+ calculate_priority(mime)
46
+ end
47
+
48
+ # @since 1.0.1
49
+ # @api private
50
+ def <=>(other)
51
+ return priority <=> other.priority unless priority == other.priority
52
+
53
+ other.index <=> index
54
+ end
55
+
56
+ private
57
+
58
+ # @since 1.0.1
59
+ # @api private
60
+ def calculate_priority(mime)
61
+ @priority ||= (mime.split(MIME_SEPARATOR, 2).count(MIME_WILDCARD) * -10) + quality
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,438 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hanami/utils"
4
+ require "rack/utils"
5
+ require "rack/mime"
6
+ require_relative "errors"
7
+
8
+ module Hanami
9
+ class Action
10
+ # @api private
11
+ module Mime
12
+ # Most commom media types used for responses
13
+ #
14
+ # @since 1.0.0
15
+ # @api public
16
+ TYPES = {
17
+ atom: "application/atom+xml",
18
+ avi: "video/x-msvideo",
19
+ bmp: "image/bmp",
20
+ bz2: "application/x-bzip2",
21
+ bz: "application/x-bzip",
22
+ chm: "application/vnd.ms-htmlhelp",
23
+ css: "text/css",
24
+ csv: "text/csv",
25
+ flv: "video/x-flv",
26
+ form: "application/x-www-form-urlencoded",
27
+ gif: "image/gif",
28
+ gz: "application/x-gzip",
29
+ h264: "video/h264",
30
+ html: "text/html",
31
+ ico: "image/vnd.microsoft.icon",
32
+ ics: "text/calendar",
33
+ jpg: "image/jpeg",
34
+ js: "application/javascript",
35
+ json: "application/json",
36
+ manifest: "text/cache-manifest",
37
+ mov: "video/quicktime",
38
+ mp3: "audio/mpeg",
39
+ mp4: "video/mp4",
40
+ mp4a: "audio/mp4",
41
+ mpg: "video/mpeg",
42
+ multipart: "multipart/form-data",
43
+ oga: "audio/ogg",
44
+ ogg: "application/ogg",
45
+ ogv: "video/ogg",
46
+ pdf: "application/pdf",
47
+ pgp: "application/pgp-encrypted",
48
+ png: "image/png",
49
+ psd: "image/vnd.adobe.photoshop",
50
+ rss: "application/rss+xml",
51
+ rtf: "application/rtf",
52
+ sh: "application/x-sh",
53
+ svg: "image/svg+xml",
54
+ swf: "application/x-shockwave-flash",
55
+ tar: "application/x-tar",
56
+ torrent: "application/x-bittorrent",
57
+ tsv: "text/tab-separated-values",
58
+ txt: "text/plain",
59
+ uri: "text/uri-list",
60
+ vcs: "text/x-vcalendar",
61
+ wav: "audio/x-wav",
62
+ webm: "video/webm",
63
+ wmv: "video/x-ms-wmv",
64
+ woff2: "application/font-woff2",
65
+ woff: "application/font-woff",
66
+ wsdl: "application/wsdl+xml",
67
+ xhtml: "application/xhtml+xml",
68
+ xml: "application/xml",
69
+ xslt: "application/xslt+xml",
70
+ yml: "text/yaml",
71
+ zip: "application/zip"
72
+ }.freeze
73
+
74
+ # @api private
75
+ ANY_TYPE = "*/*"
76
+
77
+ # @api private
78
+ Format = Data.define(:name, :media_type, :accept_types, :content_types) do
79
+ def initialize(name:, media_type:, accept_types: [media_type], content_types: [media_type])
80
+ super
81
+ end
82
+ end
83
+
84
+ # @api private
85
+ FORMATS = TYPES
86
+ .to_h { |name, media_type| [name, Format.new(name:, media_type:)] }
87
+ .update(
88
+ all: Format.new(
89
+ name: :all,
90
+ media_type: "application/octet-stream",
91
+ accept_types: ["*/*"],
92
+ content_types: ["*/*"]
93
+ ),
94
+ html: Format.new(
95
+ name: :html,
96
+ media_type: "text/html",
97
+ content_types: ["application/x-www-form-urlencoded", "multipart/form-data"]
98
+ )
99
+ )
100
+ .freeze
101
+ private_constant :FORMATS
102
+
103
+ # @api private
104
+ MEDIA_TYPES_TO_FORMATS = FORMATS.each_with_object({}) { |(_name, format), hsh|
105
+ hsh[format.media_type] = format
106
+ }.freeze
107
+ private_constant :MEDIA_TYPES_TO_FORMATS
108
+
109
+ # @api private
110
+ ACCEPT_TYPES_TO_FORMATS = FORMATS.each_with_object({}) { |(_name, format), hsh|
111
+ format.accept_types.each { |type| hsh[type] = format }
112
+ }.freeze
113
+ private_constant :ACCEPT_TYPES_TO_FORMATS
114
+
115
+ class << self
116
+ # Yields if an action is configured with `formats`, the request has an `Accept` header, and
117
+ # none of the Accept types matches the accepted formats. The given block is expected to halt
118
+ # the request handling.
119
+ #
120
+ # If any of these conditions are not met, then the request is acceptable and the method
121
+ # returns without yielding.
122
+ #
123
+ # @see Action#enforce_accepted_media_types
124
+ # @see Config#formats
125
+ #
126
+ # @api private
127
+ def enforce_accept(request, config)
128
+ return unless request.accept_header?
129
+
130
+ accept_types = ::Rack::Utils.q_values(request.accept).map(&:first)
131
+ return if accept_types.any? { |type| accepted_type?(type, config) }
132
+
133
+ yield
134
+ end
135
+
136
+ # Yields if an action is configured with `formats`, the request has a `Content-Type` header,
137
+ # and the content type does not match the accepted formats. The given block is expected to
138
+ # halt the request handling.
139
+ #
140
+ # If any of these conditions are not met, then the request is acceptable and the method
141
+ # returns without yielding.
142
+ #
143
+ # @see Action#enforce_accepted_media_types
144
+ # @see Config#formats
145
+ #
146
+ # @api private
147
+ def enforce_content_type(request, config)
148
+ # Compare media type (without parameters) instead of full Content-Type header to avoid
149
+ # false negatives (e.g., multipart/form-data; boundary=...)
150
+ media_type = request.media_type
151
+
152
+ return if media_type.nil?
153
+
154
+ return if accepted_content_type?(media_type, config)
155
+
156
+ yield
157
+ end
158
+
159
+ # Returns a string combining a media type and charset, intended for setting as the
160
+ # `Content-Type` header for the response to the given request.
161
+ #
162
+ # This uses the request's `Accept` header (if present) along with the configured formats to
163
+ # determine the best content type to return.
164
+ #
165
+ # @return [String]
166
+ #
167
+ # @see Action#call
168
+ #
169
+ # @api private
170
+ def response_content_type_with_charset(request, config)
171
+ content_type_with_charset(
172
+ response_content_type(request, config),
173
+ config.default_charset || Action::DEFAULT_CHARSET
174
+ )
175
+ end
176
+
177
+ # Returns the default response Content-Type (with charset) for a request without a usable
178
+ # `Accept` header. This depends only on config, so an action can compute it once and reuse
179
+ # it across requests instead of recomputing per call.
180
+ #
181
+ # @return [String]
182
+ #
183
+ # @see Action#call
184
+ #
185
+ # @api private
186
+ def default_response_content_type_with_charset(config)
187
+ content_type_with_charset(
188
+ default_response_content_type(config),
189
+ config.default_charset || Action::DEFAULT_CHARSET
190
+ )
191
+ end
192
+
193
+ # Returns a format name for the given content type.
194
+ #
195
+ # The format name will come from the configured formats, if such a format is configured
196
+ # there, or instead from the default list of formats in `Mime::TYPES`.
197
+ #
198
+ # Returns nil if no matching format can be found.
199
+ #
200
+ # This is used to return the format name a {Response}.
201
+ #
202
+ # @example
203
+ # format_from_media_type("application/json;charset=utf-8", config) # => :json
204
+ #
205
+ # @return [Symbol, nil]
206
+ #
207
+ # @see Response#format
208
+ # @see Action#finish
209
+ #
210
+ # @api private
211
+ def format_from_media_type(media_type, config)
212
+ return if media_type.nil?
213
+
214
+ mt = extract_media_type(media_type)
215
+ config.formats.format_for(mt) || MEDIA_TYPES_TO_FORMATS[mt]&.name
216
+ end
217
+
218
+ # Returns a format name and content type pair for a given format name or content type
219
+ # string.
220
+ #
221
+ # @example
222
+ # format_and_media_type(:json, config)
223
+ # # => [:json, "application/json"]
224
+ #
225
+ # format_and_media_type("application/json", config)
226
+ # # => [:json, "application/json"]
227
+ #
228
+ # @example Unknown format name
229
+ # format_and_media_type(:unknown, config)
230
+ # # raises Hanami::Action::UnknownFormatError
231
+ #
232
+ # @example Unknown content type
233
+ # format_and_media_type("application/unknown", config)
234
+ # # => [nil, "application/unknown"]
235
+ #
236
+ # @return [Array<(Symbol, String)>]
237
+ #
238
+ # @raise [Hanami::Action::UnknownFormatError] if an unknown format name is given
239
+ #
240
+ # @api private
241
+ def format_and_media_type(value, config)
242
+ case value
243
+ when Symbol
244
+ [value, format_to_media_type(value, config)]
245
+ when String
246
+ [format_from_media_type(value, config), value]
247
+ else
248
+ raise UnknownFormatError.new(value)
249
+ end
250
+ end
251
+
252
+ # Returns a string combining the given content type and charset, intended for setting as a
253
+ # `Content-Type` header.
254
+ #
255
+ # @example
256
+ # Mime.content_type_with_charset("application/json", "utf-8")
257
+ # # => "application/json; charset=utf-8"
258
+ #
259
+ # @param content_type [String]
260
+ # @param charset [String]
261
+ #
262
+ # @return [String]
263
+ #
264
+ # @api private
265
+ def content_type_with_charset(content_type, charset)
266
+ "#{content_type}; charset=#{charset}"
267
+ end
268
+
269
+ # Extracts the media type from a Content-Type header value, removing parameters
270
+ # like charset, boundary, etc.
271
+ #
272
+ # @param content_type [String, nil] the Content-Type header value
273
+ # @return [String, nil] the media type without parameters, downcased
274
+ #
275
+ # @example
276
+ # extract_media_type("application/json; charset=utf-8")
277
+ # # => "application/json"
278
+ #
279
+ # extract_media_type("multipart/form-data; boundary=----WebKitFormBoundary")
280
+ # # => "multipart/form-data"
281
+ #
282
+ # @api private
283
+ def extract_media_type(content_type)
284
+ return nil if content_type.nil? || content_type.empty?
285
+
286
+ # Strip charset, boundary, and other parameters (separated by semicolon)
287
+ content_type.split(";", 2).first.strip.downcase
288
+ end
289
+
290
+ # Patched version of <tt>Rack::Utils.best_q_match</tt>.
291
+ #
292
+ # @api private
293
+ #
294
+ # @see http://www.rubydoc.info/gems/rack/Rack/Utils#best_q_match-class_method
295
+ # @see https://github.com/rack/rack/pull/659
296
+ # @see https://github.com/hanami/hanami-action/issues/59
297
+ # @see https://github.com/hanami/hanami-action/issues/104
298
+ # @see https://github.com/hanami/hanami-action/issues/275
299
+ def best_q_match(q_value_header, available_mimes)
300
+ ::Rack::Utils.q_values(q_value_header).each_with_index.map { |(req_mime, quality), index|
301
+ match = available_mimes.find { |am| ::Rack::Mime.match?(am, req_mime) }
302
+ next unless match
303
+
304
+ RequestMimeWeight.new(req_mime, quality, index, match)
305
+ }.compact.max&.format
306
+ end
307
+
308
+ # Checks if a content type is acceptable for the configured formats.
309
+ #
310
+ # @param content_type [String] the media type to check
311
+ # @param config [Hanami::Action::Config] action configuration
312
+ # @return [Boolean] true if acceptable
313
+ #
314
+ # @api private
315
+ def accepted_content_type?(content_type, config)
316
+ accepted_content_types(config).any? { |accepted_content_type|
317
+ ::Rack::Mime.match?(content_type, accepted_content_type)
318
+ }
319
+ end
320
+
321
+ private
322
+
323
+ # @api private
324
+ def accepted_type?(media_type, config)
325
+ accepted_types(config).any? { |accepted_type|
326
+ ::Rack::Mime.match?(media_type, accepted_type)
327
+ }
328
+ end
329
+
330
+ # @api private
331
+ def accepted_types(config)
332
+ return [ANY_TYPE] if config.formats.empty?
333
+
334
+ config.formats.map { |format| format_to_accept_types(format, config) }.flatten(1)
335
+ end
336
+
337
+ def format_to_accept_types(format, config)
338
+ configured_types = config.formats.accept_types_for(format)
339
+ return configured_types if configured_types.any?
340
+
341
+ FORMATS
342
+ .fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) }
343
+ .accept_types
344
+ end
345
+
346
+ # @api private
347
+ def accepted_content_types(config)
348
+ return [ANY_TYPE] if config.formats.empty?
349
+
350
+ config.formats.map { |format| format_to_content_types(format, config) }.flatten(1)
351
+ end
352
+
353
+ # @api private
354
+ def format_to_content_types(format, config)
355
+ configured_types = config.formats.content_types_for(format)
356
+ return configured_types if configured_types.any?
357
+
358
+ FORMATS
359
+ .fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) }
360
+ .content_types
361
+ end
362
+
363
+ # @api private
364
+ def response_content_type(request, config)
365
+ # This method prepares the default `Content-Type` for the response. Importantly, it only
366
+ # does this after `#enforce_accept` and `#enforce_content_type` have already passed the
367
+ # request. So by the time we get here, the request has been deemed acceptable to the
368
+ # action, so we can try to be as helpful as possible in setting an appropriate content
369
+ # type for the response.
370
+
371
+ if request.accept_header?
372
+ content_type =
373
+ if config.formats.empty? || config.formats.accepted.include?(:all)
374
+ permissive_response_content_type(request, config)
375
+ else
376
+ restrictive_response_content_type(request, config)
377
+ end
378
+
379
+ return content_type if content_type
380
+ end
381
+
382
+ default_response_content_type(config)
383
+ end
384
+
385
+ # Returns the response media type for a request without a usable `Accept` header: the
386
+ # configured default format's media type, or the global default content type.
387
+ #
388
+ # @api private
389
+ def default_response_content_type(config)
390
+ if config.formats.default
391
+ format_to_media_type(config.formats.default, config)
392
+ else
393
+ Action::DEFAULT_CONTENT_TYPE
394
+ end
395
+ end
396
+
397
+ # @api private
398
+ def permissive_response_content_type(request, config)
399
+ # If no accepted formats are configured, or if the formats include :all, then we're
400
+ # working with a "permissive" action. In this case we simply want a response content type
401
+ # that corresponds to the request's accept header as closely as possible. This means we
402
+ # work from _all_ the media types we know of.
403
+
404
+ all_media_types =
405
+ (ACCEPT_TYPES_TO_FORMATS.keys | MEDIA_TYPES_TO_FORMATS.keys) +
406
+ config.formats.accept_types
407
+
408
+ best_q_match(request.accept, all_media_types)
409
+ end
410
+
411
+ # @api private
412
+ def restrictive_response_content_type(request, config)
413
+ # When specific formats are configured, this is a "resitrctive" action. Here we want to
414
+ # match against the configured accept types only, and work back from those to the
415
+ # configured format, so we can use its canonical media type for the content type.
416
+
417
+ accept_types_to_formats = config.formats.accepted_formats(FORMATS)
418
+ .each_with_object({}) { |(_, format), hsh|
419
+ format.accept_types.each { hsh[_1] = format }
420
+ }
421
+
422
+ accept_type = best_q_match(request.accept, accept_types_to_formats.keys)
423
+ accept_types_to_formats[accept_type].media_type if accept_type
424
+ end
425
+
426
+ # @api private
427
+ def format_to_media_type(format, config)
428
+ configured_type = config.formats.media_type_for(format)
429
+ return configured_type if configured_type
430
+
431
+ FORMATS
432
+ .fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) }
433
+ .media_type
434
+ end
435
+ end
436
+ end
437
+ end
438
+ end