From e9adf2109bf9b1a6864ccdfccf6faea436ea78ff Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 4 Jul 2026 14:27:56 +0300 Subject: [PATCH] fix: match vendor media type case-insensitively The request media type is lowercased on the way in, but the configured vendor was compared as-is, so a mixed-case vendor (e.g. application/vnd.Some.Name+json) never matched and requests silently fell through to Ignore / the v1.0 default. Media types are case-insensitive (RFC 9110 8.3.1, RFC 6838 4.2), so lowercase the vendor in the comparison. The response content-type still echoes the configured casing. Co-Authored-By: Claude Opus 4.8 (1M context) --- fast_version/accept.py | 4 +- .../change.md | 43 +++++++++++++++++++ planning/deferred.md | 7 +-- tests/test_accept.py | 8 ++++ 4 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 planning/changes/2026-07-04.02-vendor-media-type-case-insensitive/change.md diff --git a/fast_version/accept.py b/fast_version/accept.py index e802ccc..529b1f7 100644 --- a/fast_version/accept.py +++ b/fast_version/accept.py @@ -42,7 +42,9 @@ def parse_accept_version(accept_header: str, vendor_media_type: str) -> AcceptVe except ValueError: return Ignore() - if media_type.strip() != vendor_media_type: + # Media types are case-insensitive (RFC 9110 8.3.1); the header side is already + # lowercased by get_accept_header_from_scope, so lowercase the vendor to match. + if media_type.strip() != vendor_media_type.lower(): return Ignore() version_key = "" diff --git a/planning/changes/2026-07-04.02-vendor-media-type-case-insensitive/change.md b/planning/changes/2026-07-04.02-vendor-media-type-case-insensitive/change.md new file mode 100644 index 0000000..ca03b84 --- /dev/null +++ b/planning/changes/2026-07-04.02-vendor-media-type-case-insensitive/change.md @@ -0,0 +1,43 @@ +--- +summary: Match the vendor media type case-insensitively per RFC 9110/6838, so a mixed-case configured vendor no longer silently falls through to Ignore. +--- + +# Change: Case-insensitive vendor media-type matching + +**Lane:** lightweight — one-line behavior fix in `accept.py` plus one seam test; +no new file, no public-API change. + +## Goal + +`parse_accept_version` compared the request media type (already lowercased by +`get_accept_header_from_scope`) against `vendor_media_type` as configured. A +mixed-case vendor (e.g. `application/vnd.Some.Name+json`) never matched and the +request silently fell through to `Ignore`, defaulting to v1.0. Media types are +case-insensitive (RFC 9110 §8.3.1, RFC 6838 §4.2), so the match must be too. +Resolves the item recorded in [`../../deferred.md`](../../deferred.md). + +## Approach + +Lowercase the vendor in the comparison only: +`media_type.strip() != vendor_media_type.lower()`. This is the robustness +principle — liberal in what we accept (case-insensitive match), conservative in +what we send: the response `content-type` still echoes the exact casing the user +configured (that string is unchanged, so no emitted-header behavior changes). +Normalizing at `init` was rejected because it would also rewrite the emitted +content-type casing — a change beyond the bug, with no spec basis. + +## Files + +- `fast_version/accept.py` — `.lower()` on the vendor in the media-type check. +- `tests/test_accept.py` — `test_parse_matches_vendor_case_insensitively`: a + mixed-case vendor matches a lowercased request media type → `ParsedVersion`. +- `planning/deferred.md` — remove the now-resolved entry. + +## Verification + +- [x] Failing test first — `pytest tests/test_accept.py::test_parse_matches_vendor_case_insensitively` + → `AssertionError: Ignore() == ParsedVersion(version=(1, 0))`. +- [x] Apply the `.lower()` fix. +- [x] Test passes. +- [ ] `just test-ci` — full suite green, coverage 100%. +- [ ] `just lint-ci` — ruff + ty clean, `planning: OK`. diff --git a/planning/deferred.md b/planning/deferred.md index 0ce4032..7006284 100644 --- a/planning/deferred.md +++ b/planning/deferred.md @@ -2,9 +2,4 @@ Real-but-unscheduled items, each with a revisit trigger. -- **Vendor media-type case-sensitivity** — `parse_accept_version` compares a - lowercased header media-type against a non-lowercased `vendor_media_type`, - so a mixed-case vendor never matches (silently falls through to `Ignore`). - Revisit trigger: a user configures a vendor type with uppercase letters, or - we decide to guarantee case-insensitive media-type matching. Fix is a - failing `tests/test_accept.py` case plus a `.lower()` on the vendor. +_(none)_ diff --git a/tests/test_accept.py b/tests/test_accept.py index dd9325c..e5bd5d0 100644 --- a/tests/test_accept.py +++ b/tests/test_accept.py @@ -56,6 +56,14 @@ def test_parse_bad_version_format(version: str) -> None: assert result == ParseError(detail="Version should be in . format") +def test_parse_matches_vendor_case_insensitively() -> None: + # Media types are case-insensitive (RFC 9110 8.3.1, RFC 6838 4.2): a mixed-case + # configured vendor must still match the lowercased request media type. + header: typing.Final = f"{VENDOR}; version=1.0" + mixed_case_vendor: typing.Final = "application/vnd.Some.Name+json" + assert parse_accept_version(header, mixed_case_vendor) == ParsedVersion(version=(1, 0)) + + def test_get_accept_header_from_scope_normalizes() -> None: scope: typing.Final = {"type": "http", "headers": [(b"accept", b" Foo/Bar ")]} assert get_accept_header_from_scope(scope) == "foo/bar"