Skip to content
Merged
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
4 changes: 3 additions & 1 deletion fast_version/accept.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
Original file line number Diff line number Diff line change
@@ -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`.
7 changes: 1 addition & 6 deletions planning/deferred.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)_
8 changes: 8 additions & 0 deletions tests/test_accept.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ def test_parse_bad_version_format(version: str) -> None:
assert result == ParseError(detail="Version should be in <major>.<minor> 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"
Expand Down