Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
* [#2827](https://github.com/ruby-grape/grape/pull/2827): Make the `cascade` DSL getter return the configured value (`cascade false` read back as `true`) - [@ericproulx](https://github.com/ericproulx).
* [#2829](https://github.com/ruby-grape/grape/pull/2829): Fix a cascading route handing over only to the last route registered for the path, making a middle version (3+ mounted versions with a catch-all) answer 406 - [@ericproulx](https://github.com/ericproulx).
* [#2826](https://github.com/ruby-grape/grape/pull/2826): Fix `api.version` not being set for the root route of a path-versioned API (`GET /v1`) - [@ericproulx](https://github.com/ericproulx).
* [#2847](https://github.com/ruby-grape/grape/pull/2847): Match `Accept` media types case-insensitively, so a differently-cased header still negotiates the content type and resolves a vendor version - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

### 3.3.5 (2026-07-30)
Expand Down
6 changes: 5 additions & 1 deletion lib/grape/middleware/formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,15 @@ def format_from_extension
extension if content_type_for(extension)
end

# Media types are case-insensitive (RFC 9110 §8.3.1) but the registered
# ones are spelled in lower case and Rack matches them literally, so an
# `Accept: TEXT/PLAIN` found nothing and fell through to the default
# format — the client quietly got something other than what it asked for.
def format_from_header
accept_header = try_scrub(env['HTTP_ACCEPT'])
return if accept_header.blank? || accept_header == ALL_MEDIA_TYPES

media_type = Rack::Utils.best_q_match(accept_header, mime_types.keys)
media_type = Rack::Utils.best_q_match(accept_header.downcase, mime_types.keys)
mime_types[media_type] if media_type
end
end
Expand Down
13 changes: 10 additions & 3 deletions lib/grape/util/media_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ class MediaType

# based on the HTTP Accept header with the pattern:
# application/vnd.:vendor-:version+:format
#
# Matched against a down-cased media type: they are case-insensitive
# (RFC 9110 §8.3.1), while a vendor and version are declared in the DSL
# in the case they will be compared in.
VENDOR_VERSION_HEADER_REGEX = /\Avnd\.(?<vendor>[a-z0-9.\-_!^]+?)(?:-(?<version>[a-z0-9*.]+))?(?:\+(?<format>[a-z0-9*\-.]+))?\z/

def initialize(type:, subtype:)
Expand Down Expand Up @@ -41,7 +45,7 @@ def best_quality(header, available_media_types)
def parse(media_type)
return if media_type.blank?

type, subtype = media_type.split('/', 2)
type, subtype = media_type.downcase.split('/', 2)
return if type.blank? || subtype.blank?

new(type:, subtype:)
Expand All @@ -50,14 +54,17 @@ def parse(media_type)
def match?(media_type)
return false if media_type.blank?

subtype = media_type.split('/', 2).last
subtype = media_type.downcase.split('/', 2).last
return false if subtype.blank?

VENDOR_VERSION_HEADER_REGEX.match?(subtype)
end

# The available types are registered in lower case and Rack matches them
# literally, so the header has to be down-cased to be compared against
# them at all.
def best_quality_media_type(header, available_media_types)
header.blank? ? available_media_types.first : Rack::Utils.best_q_match(header, available_media_types)
header.blank? ? available_media_types.first : Rack::Utils.best_q_match(header.downcase, available_media_types)
end
end

Expand Down
26 changes: 26 additions & 0 deletions spec/grape/api_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4183,6 +4183,32 @@ def my_method
end
end

# Media types are case-insensitive (RFC 9110 §8.3.1). The registered ones are
# spelled in lower case and matched literally, so a differently-cased Accept
# used to find nothing: content negotiation fell through to the default format
# and header versioning behaved as though no version had been asked for.
describe 'a differently-cased Accept header' do
it 'still negotiates the content type' do
subject.content_type :json, 'application/json'
subject.content_type :txt, 'text/plain'
subject.default_format :json
subject.get('/x') { { a: 1 } }

get '/x', {}, 'HTTP_ACCEPT' => 'TEXT/PLAIN'
expect(last_response.headers[Rack::CONTENT_TYPE]).to eq('text/plain')
end

it 'still resolves the version of a vendor media type' do
subject.version 'v1', using: :header, vendor: 'twitter'
subject.format :json
subject.get('/x') { env[Grape::Env::API_VERSION] }

get '/x', {}, 'HTTP_ACCEPT' => 'APPLICATION/VND.TWITTER-V1+JSON'
expect(last_response.status).to eq(200)
expect(last_response.body).to eq('v1'.to_json)
end
end

describe '.format' do
context ':txt' do
before do
Expand Down
19 changes: 19 additions & 0 deletions spec/grape/util/media_type_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@
it_behaves_like 'MediaType'
end
end

# Media types are case-insensitive (RFC 9110 §8.3.1); the vendor pattern is
# written in lower case, so anything else used to parse as no vendor at all.
context 'when the header is not in lower case' do
subject(:media_type) { described_class.parse(header) }

let(:header) { 'APPLICATION/VND.TEST-V1+JSON' }

it 'parses the vendor, version and format' do
expect(media_type.vendor).to eq('test')
expect(media_type.version).to eq('v1')
expect(media_type.format).to eq('json')
end

it 'down-cases the type and subtype' do
expect(media_type.type).to eq('application')
expect(media_type.subtype).to eq('vnd.test-v1+json')
end
end
end

describe '.match?' do
Expand Down
Loading