Add Accept-header content negotiation (RFC 7231 §5.3.2)#13
Conversation
http could parse a Content-Type (MediaType) but a server had no way to honor a client's Accept header and pick the best response representation. Adds a MediaRange type and an Accept module that parses an Accept value into weighted media ranges (reusing the MediaType parser, so wildcards and parameters are handled) and negotiates the best offer: each offer takes the weight of its most specific matching range (exact > type/* > */*), a weight of 0 or no match makes it unacceptable, the highest weight wins with ties broken by offer order, and an empty Accept accepts anything. Request.negotiate reads the header off a parsed request. Purely additive; 15 tests, all green (fmt/angler clean).
There was a problem hiding this comment.
Build & Tests
Checked out claude/accept-content-negotiation at HEAD (649b0ff, matches the PR head). It compiles, carp-fmt -c and angler are clean, and gendocs.carp regenerates without error — the three gating CI steps.
A note on the checkmark: http's Run tests step is continue-on-error: true (ci.yml:23), so the green test check does not actually assert the tests passed — only Lint/Format/Docs gate. I therefore ran the suite myself: carp -x test/http.carp → 103 pass / 1 fail, where the single failure is the pre-existing form-data on empty body armhf split-by "" artifact (it also fails on master, 88/1, and is green on CI). All 15 new Accept tests pass.
Findings
I went beyond the suite and ran an adversarial probe of the negotiation against the live module; the logic is RFC 7231 §5.3.2-correct on every case I could construct:
q=0(andq<0, clamped) makes an offer unacceptable — verifiedDouble.from-string "0"is(Just 0.0), the dependency this rests on;- most-specific match wins even at lower weight (
*/*;q=0.9, text/html;q=0.1→ servesimage/png), and a specific;q=0excludes a type even when a broader range would accept it (text/*;q=1, text/html;q=0→ html excluded,text/plainstill served); - case-insensitive on both sides —
TEXT/HTMLmatches offertext/htmland vice-versa, and an uppercaseQ=0param is honored (both accept-ranges and offers pass throughMediaType.parse, which lower-cases type/subtype and param names); q>1clamps to 1; malformed/garbage ranges are skipped; a trailing comma is tolerated; empty offers →Nothing; equal-quality offers break by offer order (verified 3-way).
The code is clean and idiomatic — private/hidden helpers, doc strings, a MediaRange deftype, and it reuses the MediaType parser from #12 rather than re-parsing. Tests are meaningful (specificity precedence, q=0, wildcards, tie-break, whitespace, empty/absent Accept, both Request.negotiate paths). No CHANGELOG in this repo (feature PRs #8/#12 didn't add one either).
Two minor, non-blocking observations:
Request.negotiatereads only the firstAcceptheader line. It reusesRequest.header, which returnsArray.unsafe-firstof a repeated header (http.carp:172-183). A client that splitsAcceptacross multiple header lines would have the later lines ignored (RFC 7230 §3.2.2 says treat repeats as comma-joined). This is inherited existing behavior —Content-Typenegotiation has the same shape — so it's arguably intentional, not a regression from this PR.Accept.parsesplits on,beforeMediaTypeparsing, so a parameter value containing a quoted comma (text/html;foo="a,b") is split and the fragment dropped. It degrades gracefully (the media type still parses and is served; theqparam never contains commas), so realistically harmless — worth at most a doc note.
Verdict: merge
Purely additive, RFC-correct, well-tested, and clean; it turns the #12 MediaType parser into a usable negotiation API with no behavior change to existing code. The two notes above are edge-case polish, not blockers. The only process step: the PR is still a draft — it should be marked ready-for-review before merge (there's no design fork to resolve here, unlike the chunked-encoding draft, since negotiation is a standalone helper).
What
Adds server-side HTTP content negotiation — the ability to honor a request's
Acceptheader and pick the best response media type — which the library was missing. It parsesContent-Type(MediaType) but had no way to answer "givenAccept: text/html, application/json;q=0.9, which of the formats I can produce should I send?"Two pieces, both purely additive (no existing behavior changes):
Accept.parseturns anAcceptvalue into its weighted media ranges, in header order. It delegates to the existingMediaTypeparser, so wildcards (*/*,type/*) and parameters come for free; malformed ranges are skipped. Each range is a newMediaRange(type,subtype,q).Accept.negotiate accept offersreturns the best(Maybe String)to serve, per RFC 7231 §5.3.2:type/subtypebeatstype/*beats*/*;0(or no matching range) makes an offer unacceptable;offers(server preference);Acceptaccepts anything and yields the first offer.Request.negotiate r offersis the convenience that reads theAcceptheader off a parsedRequestand delegates (a request with noAcceptaccepts anything).Why
Content negotiation is a core part of an HTTP server's job. The
MediaTypeparser merged in #12 was the missing building block; this turns it into a usable negotiation API. It's small, reuses what's already there, and is fully unit-testable without a live server.Example
Tests
15 new tests covering q defaulting/parsing, whitespace tolerance, specificity precedence (a specific range overriding
*/*),q=0exclusion,type/*and*/*wildcards, tie-breaking by offer order, empty/absentAccept, and bothRequest.negotiatepaths. The full suite passes (carp -x test/http.carp),carp-fmt -candanglerclean. (The one unrelated local failure,form-data on empty body, is a pre-existing armhfsplit-by ""artifact that is green on CI.)Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.