hanami-controller 2.3.0.beta1 → 2.3.0.beta2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16424b402710c09cc6727cf6ab94c84469edaa5d8e93f12d18c1f058cb973f00
4
- data.tar.gz: '0639c98ced9c6f1cca83891bd74650f8dc6856e64490aa3a3321069810cd86a7'
3
+ metadata.gz: 194fc2615c1034ce507513f72e4f218a45fafe60ad53823e0b04e07cae0469f6
4
+ data.tar.gz: fff5370bd197844f24e83d88490a950fac8cb44c1683c489da94cbb8b5d92402
5
5
  SHA512:
6
- metadata.gz: e353e4df54518fcdc9fc2ae60262b839772fdcc180335d56c3f451a26766dcfb31567ca6a45b852ffabe92e40638cdb1f259d55bb1539ec315bc15c2bfe70de7
7
- data.tar.gz: 0434befd1175d44411ad2aa5b315ba5c212c69d47dbb2c6c990733ca07f1c4c3d1954139f21c0cd6b35d236f5be9f4b9293e286f50f189a3eac24069435fca1a
6
+ metadata.gz: f61ceab1aba8a83b459e1c07698802f45fc27f5852454578a4cb135cf520bcf6c12a9a70ae90763dbdb0f1e0dde927da3eb67f071045344c44bb32bc86c8981a
7
+ data.tar.gz: f2d5905a9d93d09b4a328dbc27d564226293f89a54bb23155b5bd2ececfece6fe768a58d9e3473a228659e790c83018ee90b9f0b1ce1d783bba82679351abc1c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,79 @@
2
2
 
3
3
  Complete, fast and testable actions for Rack
4
4
 
5
+ ## v2.3.0.beta2 - 2025-10-17
6
+
7
+ ### Added
8
+
9
+ - [Tim Riley] Make format config more flexible. (#485)
10
+
11
+ **Use `config.formats.register` to register a new format and its media types.**
12
+
13
+ This replaces `config.formats.add`. Unlike `.add` it does _not_ set the format as being one of the accpeted formats at the same time.
14
+
15
+ This change makes it easier to `register` your custom formats in app config or a base action class, without inadvertently causing format restrictions in descendent action classes.
16
+
17
+ A simple registration looks like this:
18
+
19
+ ```ruby
20
+ config.formats.register(:json, "application/json")
21
+ ```
22
+
23
+ `.register` also allows you to register one or more media types for the distinct stages of request processing:
24
+
25
+ - If you want to accept requests based on different/additional media types in `Accept` request headers, provide them as `accept_types:`
26
+ - If you want to accept requests based on different/additional media types in `Content-Type` request headers, provide them as `content_types:`
27
+ - If you do not provide these options, then the _default_ media type (the required second argument, after the format name) is used for each of the above
28
+ - This default media type is also set as the default `Content-Type` _response_ header for requests that match the format
29
+
30
+ Together, these allow you to register a format like this:
31
+
32
+ ```ruby
33
+ config.formats.register(
34
+ :jsonapi,
35
+ "application/vnd.api+json",
36
+ accept_types: ["application/vnd.api+json", "application/json"],
37
+ content_types: ["application/vnd.api+json", "application/json"],
38
+ )
39
+ ```
40
+
41
+ **Use `config.formats.accept` to accept specific formats from an action.**
42
+
43
+ `formats.accept` replaces `Action.format` and `config.format`. You can access your accepted formats via `formats.accepted`, which replaces `config.formats.values`.
44
+
45
+ To accept a format:
46
+
47
+ ```ruby
48
+ config.formats.accept :html, :json
49
+ config.formats.accepted # => [:html, :json]
50
+
51
+ config.formats.accept :csv # it is additive
52
+ config.formats.accepted # => [:html, :json, :csv]
53
+ ```
54
+
55
+ The first format you give to `accept` will also become the _default format_ for responses from your action.
56
+
57
+ **Use config.formats.default=` to set an action's default format.**
58
+
59
+ This is a new capability. Assign an action's default format using `config.formats.default=`.
60
+
61
+ The default format is used to set the response `Content-Type` header when the request does not specify a format via `Accept`.
62
+
63
+ ```ruby
64
+ config.formats.accept :html, :json
65
+
66
+ # When no default is already set, the first accepted format becomes default
67
+ config.formats.default # => :html
68
+
69
+ # But you can now configure this directly
70
+ config.formats.default = :json
71
+ ```
72
+
73
+ ### Changed
74
+
75
+ - [Tim Riley] `Action.format`, `config.format`, `config.formats.add`, `config.formats.values`, and `config.formats.values=` are deprecated and will be removed in Hanami 2.4. (#485)
76
+ - [Tim Riley] Drop support for Ruby 3.1.
77
+
5
78
  ## v2.3.0.beta1 - 2025-10-03
6
79
 
7
80
  ### Fixed
data/README.md CHANGED
@@ -135,10 +135,10 @@ action = Show.new(configuration: configuration)
135
135
  response = action.call(id: 23, key: "value")
136
136
  ```
137
137
 
138
- #### Whitelisting
138
+ #### Allowlisting
139
139
 
140
140
  Params represent an untrusted input.
141
- For security reasons it's recommended to whitelist them.
141
+ For security reasons it's recommended to allowlist them.
142
142
 
143
143
  ```ruby
144
144
  require "hanami/validations"
@@ -162,11 +162,11 @@ class Signup < Hanami::Action
162
162
  puts request.params.class # => Signup::Params
163
163
  puts request.params.class.superclass # => Hanami::Action::Params
164
164
 
165
- # Whitelist :first_name, but not :admin
165
+ # Allowlist :first_name, but not :admin
166
166
  puts request.params[:first_name] # => "Luca"
167
167
  puts request.params[:admin] # => nil
168
168
 
169
- # Whitelist nested params [:address][:line_one], not [:address][:line_two]
169
+ # Allowlist nested params [:address][:line_one], not [:address][:line_two]
170
170
  puts request.params[:address][:line_one] # => "69 Tender St"
171
171
  puts request.params[:address][:line_two] # => nil
172
172
  end
@@ -18,7 +18,7 @@ Gem::Specification.new do |spec|
18
18
  spec.executables = []
19
19
  spec.require_paths = ["lib"]
20
20
  spec.metadata["rubygems_mfa_required"] = "true"
21
- spec.required_ruby_version = ">= 3.1"
21
+ spec.required_ruby_version = ">= 3.2"
22
22
 
23
23
  spec.add_dependency "rack", ">= 2.1"
24
24
  spec.add_dependency "hanami-utils", "~> 2.3.0.beta1"
@@ -11,35 +11,57 @@ module Hanami
11
11
  # @since 2.0.0
12
12
  # @api private
13
13
  class Formats
14
- include Dry.Equalizer(:values, :mapping)
14
+ include Dry.Equalizer(:accepted, :mapping)
15
15
 
16
- # Default MIME type to format mapping
17
- #
18
16
  # @since 2.0.0
19
17
  # @api private
20
- DEFAULT_MAPPING = {
21
- "application/octet-stream" => :all,
22
- "*/*" => :all
23
- }.freeze
18
+ attr_reader :mapping
24
19
 
20
+ # The array of formats to accept requests by.
21
+ #
22
+ # @example
23
+ # config.formats.accepted = [:html, :json]
24
+ # config.formats.accepted # => [:html, :json]
25
+ #
25
26
  # @since 2.0.0
26
- # @api private
27
- attr_reader :mapping
27
+ # @api public
28
+ attr_reader :accepted
29
+
30
+ # @see #accepted
31
+ #
32
+ # @since 2.0.0
33
+ # @api public
34
+ def values
35
+ msg = <<~TEXT
36
+ Hanami::Action `config.formats.values` is deprecated and will be removed in Hanami 2.4.
37
+
38
+ Please use `config.formats.accepted` instead.
39
+
40
+ See https://guides.hanamirb.org/v2.3/actions/formats-and-mime-types/ for details.
41
+ TEXT
42
+ warn(msg, category: :deprecated)
43
+
44
+ accepted
45
+ end
28
46
 
29
- # The array of enabled formats.
47
+ # Returns the default format name.
48
+ #
49
+ # When a request is received that cannot
50
+ #
51
+ # @return [Symbol, nil] the default format name, if any
30
52
  #
31
53
  # @example
32
- # config.formats.values = [:html, :json]
33
- # config.formats.values # => [:html, :json]
54
+ # @config.formats.default # => :json
34
55
  #
35
56
  # @since 2.0.0
36
57
  # @api public
37
- attr_reader :values
58
+ attr_reader :default
38
59
 
39
60
  # @since 2.0.0
40
61
  # @api private
41
- def initialize(values: [], mapping: DEFAULT_MAPPING.dup)
42
- @values = values
62
+ def initialize(accepted: [], default: nil, mapping: {})
63
+ @accepted = accepted
64
+ @default = default
43
65
  @mapping = mapping
44
66
  end
45
67
 
@@ -47,15 +69,74 @@ module Hanami
47
69
  # @api private
48
70
  private def initialize_copy(original) # rubocop:disable Style/AccessModifierDeclarations
49
71
  super
50
- @values = original.values.dup
72
+ @accepted = original.accepted.dup
73
+ @default = original.default
51
74
  @mapping = original.mapping.dup
52
75
  end
53
76
 
77
+ # !@attribute [w] accepted
78
+ # @since 2.3.0
79
+ # @api public
80
+ def accepted=(formats)
81
+ @accepted = formats.map { |f| Hanami::Utils::Kernel.Symbol(f) }
82
+ end
83
+
54
84
  # !@attribute [w] values
55
85
  # @since 2.0.0
56
86
  # @api public
57
- def values=(formats)
58
- @values = formats.map { |f| Utils::Kernel.Symbol(f) }
87
+ alias_method :values=, :accepted=
88
+
89
+ # @since 2.3.0
90
+ def accept(*formats)
91
+ self.default = formats.first if default.nil?
92
+ self.accepted = accepted | formats
93
+ end
94
+
95
+ # @api private
96
+ def accepted_formats(standard_formats = {})
97
+ accepted.to_h { |format|
98
+ [
99
+ format,
100
+ mapping.fetch(format) { standard_formats[format] }
101
+ ]
102
+ }
103
+ end
104
+
105
+ # @since 2.3.0
106
+ def default=(format)
107
+ @default = format.to_sym
108
+ end
109
+
110
+ # Registers a format and its associated media types.
111
+ #
112
+ # @param format [Symbol] the format name
113
+ # @param media_type [String] the format's media type
114
+ # @param accept_types [Array<String>] media types to accept in request `Accept` headers
115
+ # @param content_types [Array<String>] media types to accept in request `Content-Type` headers
116
+ #
117
+ # @example
118
+ # config.formats.register(:scim, media_type: "application/json+scim")
119
+ #
120
+ # config.formats.register(
121
+ # :jsonapi,
122
+ # "application/vnd.api+json",
123
+ # accept_types: ["application/vnd.api+json", "application/json"],
124
+ # content_types: ["application/vnd.api+json", "application/json"]
125
+ # )
126
+ #
127
+ # @return [self]
128
+ #
129
+ # @since 2.3.0
130
+ # @api public
131
+ def register(format, media_type, accept_types: [media_type], content_types: [media_type])
132
+ mapping[format] = Mime::Format.new(
133
+ name: format.to_sym,
134
+ media_type: media_type,
135
+ accept_types: accept_types,
136
+ content_types: content_types
137
+ )
138
+
139
+ self
59
140
  end
60
141
 
61
142
  # @overload add(format)
@@ -83,20 +164,30 @@ module Hanami
83
164
  # @param mime_types [Array<String>]
84
165
  #
85
166
  # @example
86
- # config.formats.add(:json, ["application/json+scim", "application/json"])
167
+ # config.formats.add(:json, ["application/json+scim"])
87
168
  #
88
169
  # @return [self]
89
170
  #
90
171
  # @since 2.0.0
91
172
  # @api public
92
- def add(format, mime_types = [])
93
- format = Utils::Kernel.Symbol(format)
173
+ def add(format, mime_types)
174
+ msg = <<~TEXT
175
+ Hanami::Action `config.formats.add` is deprecated and will be removed in Hanami 2.4.
176
+
177
+ Please use `config.formats.register` instead.
178
+
179
+ See https://guides.hanamirb.org/v2.3/actions/formats-and-mime-types/ for details.
180
+ TEXT
181
+ warn(msg, category: :deprecated)
182
+
183
+ mime_type = Array(mime_types).first
94
184
 
95
- Array(mime_types).each do |mime_type|
96
- @mapping[Utils::Kernel.String(mime_type)] = format
97
- end
185
+ # The old behaviour would have subsequent mime types _replacing_ previous ones
186
+ mapping.reject! { |_, format| format.media_type == mime_type }
98
187
 
99
- @values << format unless @values.include?(format)
188
+ register(format, Array(mime_types).first, accept_types: mime_types)
189
+
190
+ accept(format) unless @accepted.include?(format)
100
191
 
101
192
  self
102
193
  end
@@ -104,31 +195,19 @@ module Hanami
104
195
  # @since 2.0.0
105
196
  # @api private
106
197
  def empty?
107
- @values.empty?
198
+ accepted.empty?
108
199
  end
109
200
 
110
201
  # @since 2.0.0
111
202
  # @api private
112
203
  def any?
113
- @values.any?
204
+ @accepted.any?
114
205
  end
115
206
 
116
207
  # @since 2.0.0
117
208
  # @api private
118
209
  def map(&blk)
119
- @values.map(&blk)
120
- end
121
-
122
- # @since 2.0.0
123
- # @api private
124
- def mapping=(mappings)
125
- @mapping = {}
126
-
127
- mappings.each do |format_name, mime_types|
128
- Array(mime_types).each do |mime_type|
129
- add(format_name, mime_type)
130
- end
131
- end
210
+ @accepted.map(&blk)
132
211
  end
133
212
 
134
213
  # Clears any previously added mappings and format values.
@@ -138,15 +217,24 @@ module Hanami
138
217
  # @since 2.0.0
139
218
  # @api public
140
219
  def clear
141
- @mapping = DEFAULT_MAPPING.dup
142
- @values = []
220
+ @accepted = []
221
+ @default = nil
222
+ @mapping = {}
143
223
 
144
224
  self
145
225
  end
146
226
 
147
- # Retrieve the format name associated with the given MIME Type
227
+ # Returns an array of all accepted media types.
228
+ #
229
+ # @since 2.3.0
230
+ # @api public
231
+ def accept_types
232
+ accepted.map { |format| mapping[format]&.accept_types }.flatten(1).compact
233
+ end
234
+
235
+ # Retrieve the format name associated with the given media type
148
236
  #
149
- # @param mime_type [String] the MIME Type
237
+ # @param media_type [String] the media Type
150
238
  #
151
239
  # @return [Symbol,NilClass] the associated format name, if any
152
240
  #
@@ -157,59 +245,46 @@ module Hanami
157
245
  #
158
246
  # @since 2.0.0
159
247
  # @api public
160
- def format_for(mime_type)
161
- @mapping[mime_type]
248
+ def format_for(media_type)
249
+ mapping.values.reverse.find { |format| format.media_type == media_type }&.name
162
250
  end
163
251
 
164
- # Returns the primary MIME type associated with the given format.
252
+ # Returns the media type associated with the given format.
165
253
  #
166
254
  # @param format [Symbol] the format name
167
255
  #
168
- # @return [String, nil] the associated MIME type, if any
256
+ # @return [String, nil] the associated media type, if any
169
257
  #
170
258
  # @example
171
- # @config.formats.mime_type_for(:json) # => "application/json"
259
+ # @config.formats.media_type_for(:json) # => "application/json"
172
260
  #
173
261
  # @see #format_for
174
262
  #
175
- # @since 2.0.0
263
+ # @since 2.3.0
176
264
  # @api public
177
- def mime_type_for(format)
178
- @mapping.key(format)
265
+ def media_type_for(format)
266
+ mapping[format]&.media_type
179
267
  end
180
268
 
181
- # Returns an array of all MIME types associated with the given format.
182
- #
183
- # Returns an empty array if no such format is configured.
184
- #
185
- # @param format [Symbol] the format name
186
- #
187
- # @return [Array<String>] the associated MIME types
188
- #
189
- # @since 2.0.0
190
- # @api public
191
- def mime_types_for(format)
192
- @mapping.each_with_object([]) { |(mime_type, f), arr| arr << mime_type if format == f }
269
+ # @api private
270
+ def accept_types_for(format)
271
+ mapping[format]&.accept_types || []
193
272
  end
194
273
 
195
- # Returns the default format name
196
- #
197
- # @return [Symbol, nil] the default format name, if any
198
- #
199
- # @example
200
- # @config.formats.default # => :json
201
- #
274
+ # @api private
275
+ def content_types_for(format)
276
+ mapping[format]&.content_types || []
277
+ end
278
+
279
+ # @see #media_type_for
202
280
  # @since 2.0.0
203
281
  # @api public
204
- def default
205
- @values.first
206
- end
282
+ alias_method :mime_type_for, :media_type_for
207
283
 
284
+ # @see #media_type_for
208
285
  # @since 2.0.0
209
- # @api private
210
- def keys
211
- @mapping.keys
212
- end
286
+ # @api public
287
+ alias_method :mime_types_for, :accept_types_for
213
288
  end
214
289
  end
215
290
  end
@@ -68,7 +68,8 @@ module Hanami
68
68
 
69
69
  # Sets the format (or formats) for the action.
70
70
  #
71
- # To configure custom formats and MIME type mappings, call {Formats#add formats.add} first.
71
+ # To configure custom formats and MIME type mappings, call {Formats#register formats.register}
72
+ # first.
72
73
  #
73
74
  # @example
74
75
  # config.format :html, :json
@@ -82,10 +83,20 @@ module Hanami
82
83
  # @since 2.0.0
83
84
  # @api public
84
85
  def format(*formats)
86
+ msg = <<~TEXT
87
+ Hanami::Action `config.format` is deprecated and will be removed in Hanami 2.4.
88
+
89
+ Please use `config.formats.register` and/or `config.formats.accept` instead.
90
+
91
+ See https://guides.hanamirb.org/v2.3/actions/formats-and-mime-types/ for details.
92
+ TEXT
93
+ warn(msg, category: :deprecated)
94
+
85
95
  if formats.empty?
86
96
  self.formats.values
87
97
  else
88
98
  self.formats.values = formats
99
+ self.formats.default = formats.first
89
100
  end
90
101
  end
91
102
 
@@ -7,37 +7,39 @@ require_relative "errors"
7
7
 
8
8
  module Hanami
9
9
  class Action
10
+ # @api private
10
11
  module Mime # rubocop:disable Metrics/ModuleLength
11
- # Most commom MIME Types used for responses
12
+ # Most commom media types used for responses
12
13
  #
13
14
  # @since 1.0.0
14
- # @api private
15
+ # @api public
15
16
  TYPES = {
16
- txt: "text/plain",
17
- html: "text/html",
18
- json: "application/json",
19
- manifest: "text/cache-manifest",
20
17
  atom: "application/atom+xml",
21
18
  avi: "video/x-msvideo",
22
19
  bmp: "image/bmp",
23
- bz: "application/x-bzip",
24
20
  bz2: "application/x-bzip2",
21
+ bz: "application/x-bzip",
25
22
  chm: "application/vnd.ms-htmlhelp",
26
23
  css: "text/css",
27
24
  csv: "text/csv",
28
25
  flv: "video/x-flv",
26
+ form: "application/x-www-form-urlencoded",
29
27
  gif: "image/gif",
30
28
  gz: "application/x-gzip",
31
29
  h264: "video/h264",
30
+ html: "text/html",
32
31
  ico: "image/vnd.microsoft.icon",
33
32
  ics: "text/calendar",
34
33
  jpg: "image/jpeg",
35
34
  js: "application/javascript",
36
- mp4: "video/mp4",
35
+ json: "application/json",
36
+ manifest: "text/cache-manifest",
37
37
  mov: "video/quicktime",
38
38
  mp3: "audio/mpeg",
39
+ mp4: "video/mp4",
39
40
  mp4a: "audio/mp4",
40
41
  mpg: "video/mpeg",
42
+ multipart: "multipart/form-data",
41
43
  oga: "audio/ogg",
42
44
  ogg: "application/ogg",
43
45
  ogv: "video/ogg",
@@ -53,13 +55,14 @@ module Hanami
53
55
  tar: "application/x-tar",
54
56
  torrent: "application/x-bittorrent",
55
57
  tsv: "text/tab-separated-values",
58
+ txt: "text/plain",
56
59
  uri: "text/uri-list",
57
60
  vcs: "text/x-vcalendar",
58
61
  wav: "audio/x-wav",
59
62
  webm: "video/webm",
60
63
  wmv: "video/x-ms-wmv",
61
- woff: "application/font-woff",
62
64
  woff2: "application/font-woff2",
65
+ woff: "application/font-woff",
63
66
  wsdl: "application/wsdl+xml",
64
67
  xhtml: "application/xhtml+xml",
65
68
  xml: "application/xml",
@@ -68,9 +71,109 @@ module Hanami
68
71
  zip: "application/zip"
69
72
  }.freeze
70
73
 
74
+ # @api private
71
75
  ANY_TYPE = "*/*"
72
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
+
73
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
+
74
177
  # Returns a format name for the given content type.
75
178
  #
76
179
  # The format name will come from the configured formats, if such a format is configured
@@ -81,52 +184,50 @@ module Hanami
81
184
  # This is used to return the format name a {Response}.
82
185
  #
83
186
  # @example
84
- # detect_format("application/jsonl charset=utf-8", config) # => :json
187
+ # format_from_media_type("application/json;charset=utf-8", config) # => :json
85
188
  #
86
189
  # @return [Symbol, nil]
87
190
  #
88
191
  # @see Response#format
89
192
  # @see Action#finish
90
193
  #
91
- # @since 2.0.0
92
194
  # @api private
93
- def detect_format(content_type, config)
94
- return if content_type.nil?
195
+ def format_from_media_type(media_type, config)
196
+ return if media_type.nil?
95
197
 
96
- ct = content_type.split(";").first
97
- config.formats.format_for(ct) || TYPES.key(ct)
198
+ mt = media_type.split(";").first
199
+ config.formats.format_for(mt) || MEDIA_TYPES_TO_FORMATS[mt]&.name
98
200
  end
99
201
 
100
202
  # Returns a format name and content type pair for a given format name or content type
101
203
  # string.
102
204
  #
103
205
  # @example
104
- # detect_format_and_content_type(:json, config)
206
+ # format_and_media_type(:json, config)
105
207
  # # => [:json, "application/json"]
106
208
  #
107
- # detect_format_and_content_type("application/json", config)
209
+ # format_and_media_type("application/json", config)
108
210
  # # => [:json, "application/json"]
109
211
  #
110
212
  # @example Unknown format name
111
- # detect_format_and_content_type(:unknown, config)
213
+ # format_and_media_type(:unknown, config)
112
214
  # # raises Hanami::Action::UnknownFormatError
113
215
  #
114
216
  # @example Unknown content type
115
- # detect_format_and_content_type("application/unknown", config)
217
+ # format_and_media_type("application/unknown", config)
116
218
  # # => [nil, "application/unknown"]
117
219
  #
118
220
  # @return [Array<(Symbol, String)>]
119
221
  #
120
222
  # @raise [Hanami::Action::UnknownFormatError] if an unknown format name is given
121
223
  #
122
- # @since 2.0.0
123
224
  # @api private
124
- def detect_format_and_content_type(value, config)
225
+ def format_and_media_type(value, config)
125
226
  case value
126
227
  when Symbol
127
- [value, format_to_mime_type(value, config)]
228
+ [value, format_to_media_type(value, config)]
128
229
  when String
129
- [detect_format(value, config), value]
230
+ [format_from_media_type(value, config), value]
130
231
  else
131
232
  raise UnknownFormatError.new(value)
132
233
  end
@@ -144,34 +245,13 @@ module Hanami
144
245
  #
145
246
  # @return [String]
146
247
  #
147
- # @since 2.0.0
148
248
  # @api private
149
249
  def content_type_with_charset(content_type, charset)
150
250
  "#{content_type}; charset=#{charset}"
151
251
  end
152
252
 
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
171
-
172
253
  # Patched version of <tt>Rack::Utils.best_q_match</tt>.
173
254
  #
174
- # @since 2.0.0
175
255
  # @api private
176
256
  #
177
257
  # @see http://www.rubydoc.info/gems/rack/Rack/Utils#best_q_match-class_method
@@ -179,7 +259,7 @@ module Hanami
179
259
  # @see https://github.com/hanami/controller/issues/59
180
260
  # @see https://github.com/hanami/controller/issues/104
181
261
  # @see https://github.com/hanami/controller/issues/275
182
- def best_q_match(q_value_header, available_mimes = TYPES.values)
262
+ def best_q_match(q_value_header, available_mimes)
183
263
  ::Rack::Utils.q_values(q_value_header).each_with_index.map { |(req_mime, quality), index|
184
264
  match = available_mimes.find { |am| ::Rack::Mime.match?(am, req_mime) }
185
265
  next unless match
@@ -188,99 +268,118 @@ module Hanami
188
268
  }.compact.max&.format
189
269
  end
190
270
 
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?
205
-
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) }
271
+ private
208
272
 
209
- yield
273
+ # @api private
274
+ def accepted_type?(media_type, config)
275
+ accepted_types(config).any? { |accepted_type|
276
+ ::Rack::Mime.match?(media_type, accepted_type)
277
+ }
210
278
  end
211
279
 
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
280
  # @api private
224
- def enforce_content_type(request, config)
225
- # Compare media type (without parameters) instead of full Content-Type header
226
- # to avoid false negatives (e.g., multipart/form-data; boundary=...)
227
- media_type = request.media_type
281
+ def accepted_types(config)
282
+ return [ANY_TYPE] if config.formats.empty?
228
283
 
229
- return if media_type.nil?
284
+ config.formats.map { |format| format_to_accept_types(format, config) }.flatten(1)
285
+ end
230
286
 
231
- return if accepted_mime_type?(media_type, config)
287
+ def format_to_accept_types(format, config)
288
+ configured_types = config.formats.accept_types_for(format)
289
+ return configured_types if configured_types.any?
232
290
 
233
- yield
291
+ FORMATS
292
+ .fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) }
293
+ .accept_types
234
294
  end
235
295
 
236
- private
237
-
238
- # @since 2.0.0
239
296
  # @api private
240
- def accepted_mime_type?(mime_type, config)
241
- accepted_mime_types(config).any? { |accepted_mime_type|
242
- ::Rack::Mime.match?(mime_type, accepted_mime_type)
297
+ def accepted_content_type?(content_type, config)
298
+ accepted_content_types(config).any? { |accepted_content_type|
299
+ ::Rack::Mime.match?(content_type, accepted_content_type)
243
300
  }
244
301
  end
245
302
 
246
- # @since 2.0.0
247
303
  # @api private
248
- def accepted_mime_types(config)
304
+ def accepted_content_types(config)
249
305
  return [ANY_TYPE] if config.formats.empty?
250
306
 
251
- config.formats.map { |format| format_to_mime_types(format, config) }.flatten(1)
307
+ config.formats.map { |format| format_to_content_types(format, config) }.flatten(1)
308
+ end
309
+
310
+ # @api private
311
+ def format_to_content_types(format, config)
312
+ configured_types = config.formats.content_types_for(format)
313
+ return configured_types if configured_types.any?
314
+
315
+ FORMATS
316
+ .fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) }
317
+ .content_types
252
318
  end
253
319
 
254
- # @since 2.0.0
255
320
  # @api private
256
321
  def response_content_type(request, config)
322
+ # This method prepares the default `Content-Type` for the response. Importantly, it only
323
+ # does this after `#enforce_accept` and `#enforce_content_type` have already passed the
324
+ # request. So by the time we get here, the request has been deemed acceptable to the
325
+ # action, so we can try to be as helpful as possible in setting an appropriate content
326
+ # type for the response.
327
+
257
328
  if request.accept_header?
258
- all_mime_types = TYPES.values + config.formats.mapping.keys
259
- content_type = best_q_match(request.accept, all_mime_types)
329
+ content_type =
330
+ if config.formats.empty? || config.formats.accepted.include?(:all)
331
+ permissive_response_content_type(request, config)
332
+ else
333
+ restrictive_response_content_type(request, config)
334
+ end
260
335
 
261
336
  return content_type if content_type
262
337
  end
263
338
 
264
339
  if config.formats.default
265
- return format_to_mime_type(config.formats.default, config)
340
+ return format_to_media_type(config.formats.default, config)
266
341
  end
267
342
 
268
343
  Action::DEFAULT_CONTENT_TYPE
269
344
  end
270
345
 
271
- # @since 2.0.0
272
346
  # @api private
273
- def format_to_mime_type(format, config)
274
- config.formats.mime_type_for(format) ||
275
- TYPES.fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) }
347
+ def permissive_response_content_type(request, config)
348
+ # If no accepted formats are configured, or if the formats include :all, then we're
349
+ # working with a "permissive" action. In this case we simply want a response content type
350
+ # that corresponds to the request's accept header as closely as possible. This means we
351
+ # work from _all_ the media types we know of.
352
+
353
+ all_media_types =
354
+ (ACCEPT_TYPES_TO_FORMATS.keys | MEDIA_TYPES_TO_FORMATS.keys) +
355
+ config.formats.accept_types
356
+
357
+ best_q_match(request.accept, all_media_types)
276
358
  end
277
359
 
278
- # @since 2.0.0
279
360
  # @api private
280
- def format_to_mime_types(format, config)
281
- config.formats.mime_types_for(format).tap { |types|
282
- types << TYPES[format] if TYPES.key?(format)
283
- }
361
+ def restrictive_response_content_type(request, config)
362
+ # When specific formats are configured, this is a "resitrctive" action. Here we want to
363
+ # match against the configured accept types only, and work back from those to the
364
+ # configured format, so we can use its canonical media type for the content type.
365
+
366
+ accept_types_to_formats = config.formats.accepted_formats(FORMATS)
367
+ .each_with_object({}) { |(_, format), hsh|
368
+ format.accept_types.each { hsh[_1] = format }
369
+ }
370
+
371
+ accept_type = best_q_match(request.accept, accept_types_to_formats.keys)
372
+ accept_types_to_formats[accept_type].media_type if accept_type
373
+ end
374
+
375
+ # @api private
376
+ def format_to_media_type(format, config)
377
+ configured_type = config.formats.media_type_for(format)
378
+ return configured_type if configured_type
379
+
380
+ FORMATS
381
+ .fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) }
382
+ .media_type
284
383
  end
285
384
  end
286
385
  end
@@ -42,7 +42,7 @@ module Hanami
42
42
  new(config: Action.config.dup, content_type: Mime.best_q_match(env[Action::HTTP_ACCEPT]), env: env).tap do |r|
43
43
  r.status = status
44
44
  r.body = Http::Status.message_for(status)
45
- r.set_format(Mime.detect_format(r.content_type), config)
45
+ r.set_format(Mime.format_from_media_type(r.content_type), config)
46
46
  end
47
47
  end
48
48
 
@@ -134,7 +134,7 @@ module Hanami
134
134
  # @since 2.0.0
135
135
  # @api public
136
136
  def format
137
- @format ||= Mime.detect_format(content_type, @config)
137
+ @format ||= Mime.format_from_media_type(content_type, @config)
138
138
  end
139
139
 
140
140
  # Sets the format and associated content type for the response.
@@ -165,7 +165,7 @@ module Hanami
165
165
  # @since 2.0.0
166
166
  # @api public
167
167
  def format=(value)
168
- format, content_type = Mime.detect_format_and_content_type(value, @config)
168
+ format, content_type = Mime.format_and_media_type(value, @config)
169
169
 
170
170
  self.content_type = Mime.content_type_with_charset(content_type, charset)
171
171
 
data/lib/hanami/action.rb CHANGED
@@ -272,6 +272,15 @@ module Hanami
272
272
  # @since 2.0.0
273
273
  # @api public
274
274
  def self.format(...)
275
+ msg = <<~TEXT
276
+ Hanami::Action `format` is deprecated and will be removed in Hanami 2.4.
277
+
278
+ Please use `config.formats.accept` instead.
279
+
280
+ See https://guides.hanamirb.org/v2.3/actions/formats-and-mime-types/ for details.
281
+ TEXT
282
+ warn(msg, category: :deprecated)
283
+
275
284
  config.format(...)
276
285
  end
277
286
 
@@ -326,7 +335,7 @@ module Hanami
326
335
  session_enabled: session_enabled?
327
336
  )
328
337
 
329
- enforce_accepted_mime_types(request)
338
+ enforce_accepted_media_types(request)
330
339
 
331
340
  _run_before_callbacks(request, response)
332
341
  handle(request, response)
@@ -413,7 +422,7 @@ module Hanami
413
422
 
414
423
  # @since 2.0.0
415
424
  # @api private
416
- def enforce_accepted_mime_types(request)
425
+ def enforce_accepted_media_types(request)
417
426
  return if config.formats.empty?
418
427
 
419
428
  Mime.enforce_accept(request, config) { return halt 406 }
@@ -596,7 +605,7 @@ module Hanami
596
605
  _empty_headers(res) if _requires_empty_headers?(res)
597
606
  _empty_body(res) if res.head?
598
607
 
599
- res.set_format(Action::Mime.detect_format(res.content_type, config))
608
+ res.set_format(Mime.format_from_media_type(res.content_type, config))
600
609
  res[:params] = req.params
601
610
  res[:format] = res.format
602
611
  res
@@ -8,6 +8,6 @@ module Hanami
8
8
  #
9
9
  # @since 0.1.0
10
10
  # @api public
11
- VERSION = "2.3.0.beta1"
11
+ VERSION = "2.3.0.beta2"
12
12
  end
13
13
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hanami-controller
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0.beta1
4
+ version: 2.3.0.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luca Guidi
@@ -214,7 +214,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
214
214
  requirements:
215
215
  - - ">="
216
216
  - !ruby/object:Gem::Version
217
- version: '3.1'
217
+ version: '3.2'
218
218
  required_rubygems_version: !ruby/object:Gem::Requirement
219
219
  requirements:
220
220
  - - ">="