From 550dd628b16a92c5e2780b31952714b321870b55 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 1 Aug 2026 14:07:08 +0200 Subject: [PATCH] Match Accept media types case-insensitively Media types are case-insensitive (RFC 9110 section 8.3.1), but the registered ones are spelled in lower case and matched literally, so a differently-cased Accept header found nothing: Accept: TEXT/PLAIN -> served application/json Accept: APPLICATION/VND.TWITTER-V1+JSON -> api.version nil Neither failed loudly. Content negotiation fell through to the default format, and header versioning behaved as though no version had been asked for, so the request was served by whichever version matched first -- the client quietly got something other than what it asked for. Three sites decided this, all comparing against lower-case registered types: the formatter's Accept lookup, MediaType.best_quality_media_type, and the vendor pattern in MediaType.parse / .match?. Down-case the incoming media type at each. The vendor pattern stays lower-case, which is the case a vendor and version are declared in and therefore compared in. Grape already treats media types this way when deciding whether to escape an error body (Middleware::Error#html_content_type?, from #2789). Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + lib/grape/middleware/formatter.rb | 6 +++++- lib/grape/util/media_type.rb | 13 ++++++++++--- spec/grape/api_spec.rb | 26 ++++++++++++++++++++++++++ spec/grape/util/media_type_spec.rb | 19 +++++++++++++++++++ 5 files changed, 61 insertions(+), 4 deletions(-) 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