diff --git a/CHANGELOG.md b/CHANGELOG.md index 0049465b9..8c1e0ad38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/lib/grape/middleware/formatter.rb b/lib/grape/middleware/formatter.rb index 0eaf2c9cb..26336d1a1 100644 --- a/lib/grape/middleware/formatter.rb +++ b/lib/grape/middleware/formatter.rb @@ -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 diff --git a/lib/grape/util/media_type.rb b/lib/grape/util/media_type.rb index e93956b12..e4f041f19 100644 --- a/lib/grape/util/media_type.rb +++ b/lib/grape/util/media_type.rb @@ -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\.(?[a-z0-9.\-_!^]+?)(?:-(?[a-z0-9*.]+))?(?:\+(?[a-z0-9*\-.]+))?\z/ def initialize(type:, subtype:) @@ -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:) @@ -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 diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index ff3454f15..101b8e7aa 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -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 diff --git a/spec/grape/util/media_type_spec.rb b/spec/grape/util/media_type_spec.rb index 09e328513..ae2456f93 100644 --- a/spec/grape/util/media_type_spec.rb +++ b/spec/grape/util/media_type_spec.rb @@ -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