Skip to content

Commit 2b35fed

Browse files
authored
Formatting from header acts like Versioning from header (#2548)
1 parent 809add4 commit 2b35fed

File tree

4 files changed

+58
-67
lines changed

4 files changed

+58
-67
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
* [#2543](https://github.com/ruby-grape/grape/pull/2543): Fix array allocation on mount - [@ericproulx](https://github.com/ericproulx).
1818
* [#2546](https://github.com/ruby-grape/grape/pull/2546): Fix middleware with keywords - [@ericproulx](https://github.com/ericproulx).
1919
* [#2547](https://github.com/ruby-grape/grape/pull/2547): Remove jsonapi related code - [@ericproulx](https://github.com/ericproulx).
20+
* [#2548](https://github.com/ruby-grape/grape/pull/2548): Formatting from header acts like versioning from header - [@ericproulx](https://github.com/ericproulx).
2021
* Your contribution here.
2122

2223
### 2.3.0 (2025-02-08)

UPGRADING.md

+19
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@ Upgrading Grape
66
- Passing a class to `build_with` or `Grape.config.param_builder` has been deprecated in favor of a symbolized short_name. See `SHORTNAME_LOOKUP` in [params_builder](lib/grape/params_builder.rb).
77
- Including Grape's extensions like `Grape::Extensions::Hashie::Mash::ParamBuilder` has been deprecated in favor of using `build_with` at the route level.
88

9+
#### Accept Header Negotiation Harmonized
10+
11+
[Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept) header is now fully interpreted through `Rack::Utils.best_q_match` which is following [RFC2616 14.1](https://datatracker.ietf.org/doc/html/rfc2616#section-14.1). Since [Grape 2.1.0](https://github.com/ruby-grape/grape/blob/master/CHANGELOG.md#210-20240615), the [header versioning strategy](https://github.com/ruby-grape/grape?tab=readme-ov-file#header) was adhering to it, but `Grape::Middleware::Formatter` never did.
12+
13+
Your API might act differently since it will strictly follow the [RFC2616 14.1](https://datatracker.ietf.org/doc/html/rfc2616#section-14.1) when interpreting the `Accept` header. Here are the differences:
14+
15+
###### Invalid or missing quality ranking
16+
The following used to yield `application/xml` and now will yield `application/json` as the preferred media type:
17+
- `application/json;q=invalid,application/xml;q=0.5`
18+
- `application/json,application/xml;q=1.0`
19+
20+
For the invalid case, the value `invalid` was automatically `to_f` and `invalid.to_f` equals `0.0`. Now, since it doesn't match [Rack's regex](https://github.com/rack/rack/blob/3-1-stable/lib/rack/utils.rb#L138), its interpreted as non provided and its quality ranking equals 1.0.
21+
22+
For the non provided case, 1.0 was automatically assigned and in a case of multiple best matches, the first was returned based on Ruby's sort_by `quality`. Now, 1.0 is still assigned and the last is returned in case of multiple best matches. See [Rack's implementation](https://github.com/rack/rack/blob/e8f47608668d507e0f231a932fa37c9ca551c0a5/lib/rack/utils.rb#L167) of the RFC.
23+
24+
###### Considering the closest generic when vendor tree
25+
Excluding the [header versioning strategy](https://github.com/ruby-grape/grape?tab=readme-ov-file#header), whenever a media type with the [vendor tree](https://datatracker.ietf.org/doc/html/rfc6838#section-3.2) leading facet `vnd.` like `application/vnd.api+json` was provided, Grape would also consider its closest generic when negotiating. In that case, `application/json` was added to the negotiation. Now, it will just consider the provided media types without considering any closest generics, and you'll need to [register](https://github.com/ruby-grape/grape?tab=readme-ov-file#api-formats) it.
26+
You can find the official vendor tree registrations on [IANA](https://www.iana.org/assignments/media-types/media-types.xhtml)
27+
928
### Upgrading to >= 2.4.0
1029

1130
#### Custom Validators

lib/grape/middleware/formatter.rb

+11-36
Original file line numberDiff line numberDiff line change
@@ -122,56 +122,31 @@ def read_rack_input(body)
122122
def negotiate_content_type
123123
fmt = format_from_extension || format_from_params || options[:format] || format_from_header || options[:default_format]
124124
if content_type_for(fmt)
125-
env[Grape::Env::API_FORMAT] = fmt
125+
env[Grape::Env::API_FORMAT] = fmt.to_sym
126126
else
127127
throw :error, status: 406, message: "The requested format '#{fmt}' is not supported."
128128
end
129129
end
130130

131131
def format_from_extension
132-
parts = request.path.split('.')
132+
request_path = request.path.try(:scrub)
133+
dot_pos = request_path.rindex('.')
134+
return unless dot_pos
133135

134-
if parts.size > 1
135-
extension = parts.last
136-
# avoid symbol memory leak on an unknown format
137-
return extension.to_sym if content_type_for(extension)
138-
end
139-
nil
136+
extension = request_path[dot_pos + 1..]
137+
extension if content_type_for(extension)
140138
end
141139

142140
def format_from_params
143-
fmt = Rack::Utils.parse_nested_query(env[Rack::QUERY_STRING])[FORMAT]
144-
# avoid symbol memory leak on an unknown format
145-
return fmt.to_sym if content_type_for(fmt)
146-
147-
fmt
141+
Rack::Utils.parse_nested_query(env[Rack::QUERY_STRING])[FORMAT]
148142
end
149143

150144
def format_from_header
151-
mime_array.each do |t|
152-
return mime_types[t] if mime_types.key?(t)
153-
end
154-
nil
155-
end
156-
157-
def mime_array
158-
accept = env[Grape::Http::Headers::HTTP_ACCEPT]
159-
return [] unless accept
160-
161-
accept_into_mime_and_quality = %r{
162-
(
163-
\w+/[\w+.-]+) # eg application/vnd.example.myformat+xml
164-
(?:
165-
(?:;[^,]*?)? # optionally multiple formats in a row
166-
;\s*q=([\w.]+) # optional "quality" preference (eg q=0.5)
167-
)?
168-
}x
169-
170-
vendor_prefix_pattern = /vnd\.[^+]+\+/
145+
accept_header = env[Grape::Http::Headers::HTTP_ACCEPT].try(:scrub)
146+
return if accept_header.blank?
171147

172-
accept.scan(accept_into_mime_and_quality)
173-
.sort_by { |_, quality_preference| -(quality_preference ? quality_preference.to_f : 1.0) }
174-
.flat_map { |mime, _| [mime, mime.sub(vendor_prefix_pattern, '')] }
148+
media_type = Rack::Utils.best_q_match(accept_header, mime_types.keys)
149+
mime_types[media_type] if media_type
175150
end
176151
end
177152
end

spec/grape/middleware/formatter_spec.rb

+27-31
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ def to_xml
8282
end
8383

8484
context 'detection' do
85+
context 'when path contains invalid byte sequence' do
86+
it 'does not raise an exception' do
87+
expect { subject.call(Rack::PATH_INFO => "/info.\x80") }.not_to raise_error
88+
end
89+
end
90+
8591
it 'uses the xml extension if one is provided' do
8692
subject.call(Rack::PATH_INFO => '/info.xml')
8793
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml)
@@ -95,8 +101,6 @@ def to_xml
95101
it 'uses the format parameter if one is provided' do
96102
subject.call(Rack::PATH_INFO => '/info', Rack::QUERY_STRING => 'format=json')
97103
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:json)
98-
subject.call(Rack::PATH_INFO => '/info', Rack::QUERY_STRING => 'format=xml')
99-
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml)
100104
end
101105

102106
it 'uses the default format if none is provided' do
@@ -116,6 +120,12 @@ def to_xml
116120
end
117121

118122
context 'accept header detection' do
123+
context 'when header contains invalid byte sequence' do
124+
it 'does not raise an exception' do
125+
expect { subject.call(Rack::PATH_INFO => '/info', Grape::Http::Headers::HTTP_ACCEPT => "Hello \x80") }.not_to raise_error
126+
end
127+
end
128+
119129
it 'detects from the Accept header' do
120130
subject.call(Rack::PATH_INFO => '/info', Grape::Http::Headers::HTTP_ACCEPT => 'application/xml')
121131
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml)
@@ -131,10 +141,10 @@ def to_xml
131141

132142
it 'handles quality rankings mixed with nothing' do
133143
subject.call(Rack::PATH_INFO => '/info', Grape::Http::Headers::HTTP_ACCEPT => 'application/json,application/xml; q=1.0')
134-
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:json)
144+
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml)
135145

136146
subject.call(Rack::PATH_INFO => '/info', Grape::Http::Headers::HTTP_ACCEPT => 'application/xml; q=1.0,application/json')
137-
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml)
147+
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:json)
138148
end
139149

140150
it 'handles quality rankings that have a default 1.0 value' do
@@ -156,30 +166,21 @@ def to_xml
156166
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml)
157167
end
158168

159-
it 'ignores invalid quality rankings' do
160-
subject.call(Rack::PATH_INFO => '/info', Grape::Http::Headers::HTTP_ACCEPT => 'application/json;q=invalid,application/xml;q=0.5')
161-
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml)
162-
subject.call(Rack::PATH_INFO => '/info', Grape::Http::Headers::HTTP_ACCEPT => 'application/xml;q=0.5,application/json;q=invalid')
163-
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml)
164-
165-
subject.call(Rack::PATH_INFO => '/info', Grape::Http::Headers::HTTP_ACCEPT => 'application/json;q=,application/xml;q=0.5')
166-
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:json)
167-
168-
subject.call(Rack::PATH_INFO => '/info', Grape::Http::Headers::HTTP_ACCEPT => 'application/json;q=nil,application/xml;q=0.5')
169-
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml)
170-
end
171-
172-
it 'parses headers with vendor and api version' do
173-
subject.call(Rack::PATH_INFO => '/info', Grape::Http::Headers::HTTP_ACCEPT => 'application/vnd.test-v1+xml')
174-
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml)
175-
end
176-
177169
context 'with custom vendored content types' do
178-
subject { described_class.new(app, content_types: { custom: 'application/vnd.test+json' }) }
170+
context 'when registered' do
171+
subject { described_class.new(app, content_types: { custom: 'application/vnd.test+json' }) }
179172

180-
it 'uses the custom type' do
181-
subject.call(Rack::PATH_INFO => '/info', Grape::Http::Headers::HTTP_ACCEPT => 'application/vnd.test+json')
182-
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:custom)
173+
it 'uses the custom type' do
174+
subject.call(Rack::PATH_INFO => '/info', Grape::Http::Headers::HTTP_ACCEPT => 'application/vnd.test+json')
175+
expect(subject.env[Grape::Env::API_FORMAT]).to eq(:custom)
176+
end
177+
end
178+
179+
context 'when unregistered' do
180+
it 'returns the default content type text/plain' do
181+
r = Rack::MockResponse[*subject.call(Rack::PATH_INFO => '/info', Grape::Http::Headers::HTTP_ACCEPT => 'application/vnd.test+json')]
182+
expect(r.headers[Rack::CONTENT_TYPE]).to eq('text/plain')
183+
end
183184
end
184185
end
185186

@@ -216,11 +217,6 @@ def to_xml
216217
_, headers, = s.call(Rack::PATH_INFO => '/info', Grape::Http::Headers::HTTP_ACCEPT => 'application/vnd.test+json')
217218
expect(headers[Rack::CONTENT_TYPE]).to eq('application/vnd.test+json')
218219
end
219-
220-
it 'is set to closest generic for custom vendored/versioned without registered type' do
221-
_, headers, = subject.call(Rack::PATH_INFO => '/info', Grape::Http::Headers::HTTP_ACCEPT => 'application/vnd.test+json')
222-
expect(headers[Rack::CONTENT_TYPE]).to eq('application/json')
223-
end
224220
end
225221

226222
context 'format' do

0 commit comments

Comments
 (0)