Skip to content

Match Accept media types case-insensitively - #2847

Open
ericproulx wants to merge 1 commit into
masterfrom
fix/case-insensitive-media-types
Open

Match Accept media types case-insensitively#2847
ericproulx wants to merge 1 commit into
masterfrom
fix/case-insensitive-media-types

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Summary

Media types are case-insensitive (RFC 9110 §8.3.1), but the registered ones are spelled in lower case and matched literally, so a differently-cased Accept header found nothing:

request before after
Accept: TEXT/PLAIN against a text/plain content type served application/json (the default format) text/plain
Accept: APPLICATION/VND.TWITTER-V1+JSON on a header-versioned API api.version nil "v1"
Accept: application/VND.twitter-v1+json api.version nil "v1"

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, and the client quietly got something other than what it asked for.

Approach

Three sites decided this, all comparing against the lower-case registered types:

  • Middleware::Formatter#format_from_header — the Rack::Utils.best_q_match lookup
  • Util::MediaType.best_quality_media_type — the same lookup on the versioning path
  • Util::MediaType.parse / .match? — the vendor pattern

Down-case the incoming media type at each. The vendor pattern itself stays lower-case: that is the case a vendor and version are declared in via the DSL, and therefore the case they are compared in.

Grape already treats media types this way when deciding whether to escape an error body — Middleware::Error#html_content_type?, added by #2789, compares with casecmp?.

Backward compatibility

No UPGRADING entry. A differently-cased Accept previously matched nothing, so nothing could depend on the old outcome beyond receiving the default format. Lower-case headers — effectively all real traffic — are unaffected.

Longstanding rather than a regression: identical in 3.3.4.

Test plan

  • 2 new examples in media_type_spec.rb (vendor/version/format parsed from an upper-case header, and type/subtype down-cased) and 2 in api_spec.rb (content negotiation and vendor version resolution end to end); verified all four fail without the lib/ change.
  • Full RSpec suite passes locally (2547 examples, 0 failures).
  • RuboCop clean.
  • CI green.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the fix/case-insensitive-media-types branch from f33d287 to 550dd62 Compare August 1, 2026 12:07
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant