Skip to content

Add Accept-header content negotiation (RFC 7231 §5.3.2)#13

Merged
hellerve merged 1 commit into
masterfrom
claude/accept-content-negotiation
Jul 13, 2026
Merged

Add Accept-header content negotiation (RFC 7231 §5.3.2)#13
hellerve merged 1 commit into
masterfrom
claude/accept-content-negotiation

Conversation

@carpentry-agent

Copy link
Copy Markdown

What

Adds server-side HTTP content negotiation — the ability to honor a request's Accept header and pick the best response media type — which the library was missing. It parses Content-Type (MediaType) but had no way to answer "given Accept: 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.parse turns an Accept value into its weighted media ranges, in header order. It delegates to the existing MediaType parser, so wildcards (*/*, type/*) and parameters come for free; malformed ranges are skipped. Each range is a new MediaRange (type, subtype, q).
  • Accept.negotiate accept offers returns the best (Maybe String) to serve, per RFC 7231 §5.3.2:
    • each offer takes the weight of its most specific matching range — exact type/subtype beats type/* beats */*;
    • a weight of 0 (or no matching range) makes an offer unacceptable;
    • the highest-weight acceptable offer wins, ties broken by the order of offers (server preference);
    • an empty/absent Accept accepts anything and yields the first offer.
  • Request.negotiate r offers is the convenience that reads the Accept header off a parsed Request and delegates (a request with no Accept accepts anything).

Why

Content negotiation is a core part of an HTTP server's job. The MediaType parser 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

(Accept.negotiate "text/html, application/json;q=0.9"
                  &[@"application/json" @"text/html"]) ; => (Just "text/html")

(Accept.negotiate "application/json" &[@"text/html"])  ; => Nothing (nothing acceptable)

(Request.negotiate &req &[@"application/json" @"text/html"])

Tests

15 new tests covering q defaulting/parsing, whitespace tolerance, specificity precedence (a specific range overriding */*), q=0 exclusion, type/* and */* wildcards, tie-breaking by offer order, empty/absent Accept, and both Request.negotiate paths. The full suite passes (carp -x test/http.carp), carp-fmt -c and angler clean. (The one unrelated local failure, form-data on empty body, is a pre-existing armhf split-by "" artifact that is green on CI.)


Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

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).

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.carp103 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 (and q<0, clamped) makes an offer unacceptable — verified Double.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 → serves image/png), and a specific ;q=0 excludes a type even when a broader range would accept it (text/*;q=1, text/html;q=0 → html excluded, text/plain still served);
  • case-insensitive on both sides — TEXT/HTML matches offer text/html and vice-versa, and an uppercase Q=0 param is honored (both accept-ranges and offers pass through MediaType.parse, which lower-cases type/subtype and param names);
  • q>1 clamps 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:

  1. Request.negotiate reads only the first Accept header line. It reuses Request.header, which returns Array.unsafe-first of a repeated header (http.carp:172-183). A client that splits Accept across 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-Type negotiation has the same shape — so it's arguably intentional, not a regression from this PR.
  2. Accept.parse splits on , before MediaType parsing, 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; the q param 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).

@carpentry-agent
carpentry-agent Bot marked this pull request as ready for review July 13, 2026 02:35
@hellerve
hellerve merged commit ea5f0ef into master Jul 13, 2026
2 checks passed
@hellerve
hellerve deleted the claude/accept-content-negotiation branch July 13, 2026 03:15
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