A Clojure(Script) library for proactive content negotiation by RFC 9110, section 12. It picks the media type and language of a response from the Accept fields of a request. It also works out the Vary header for that choice. When nothing is acceptable, it answers 406 with a list of the alternatives. It follows langtag-clj and whatwg-mime and builds on both: one cljc codebase, plain data in and out, the sections of the specs cited in the code, and no exception on any header that a client can send.
The examples use the namespace dk.simongray.conneg with the alias
conneg. The decisions that followed the proposal are at the end.
These findings shape the design:
text/*;q=0.5,
since * is a token character. The new library adds HTTP's matching
rules, Vary, overrides and the responses.* and never matches en to en-GB.
The library combines the two, as Apache and jshttp/negotiator do in
their own ways. Safari always sends a single language, so a lone
regional tag such as da-DK is common.da;q=abc and
on empty list elements. muuntaja and Pedestal throw on other headers that
a client can send.These are the rules the library implements, from RFC 9110 unless another spec is named:
text/*, text/html;q=0 rules out HTML but accepts other
text.I ran each library on the same headers. The offers are ["text/html" "application/json" "text/turtle"] for Accept and ["da" "en"] for
Accept-Language. Results that are wrong are in bold:
| Header | Expected | juxt/pick | liberator | ring-middleware-accept | muuntaja | Pedestal |
|---|---|---|---|---|---|---|
application/json;q=0.2, text/*;q=0.9 | text/html | text/html | text/html | text/turtle | json | json |
text/html;q=0 | nothing | nothing | text/html | nothing | text/html | text/html |
text/*, text/html;q=0 | text/turtle | text/turtle | text/html | text/turtle | text/html | text/html |
TEXT/HTML | text/html | text/html | 406 | nothing | text/html (default) | 406 |
*/* | text/html | text/html | text/html | text/turtle | text/html (default) | text/html |
application/json;q=abc | no error | nothing | json | json | json | throws |
@@@,,,;;; | no error | text/html | 406 | nothing | throws | throws |
Accept-Language da-DK | da | nothing | nothing | da | n/a | n/a |
*;q=0.5, da;q=0 | en | da | nothing | en | n/a | n/a |
da;q=abc | no error | throws | da | da | n/a | n/a |
I ran muuntaja with text/html as its default format. Without one, */*
and TEXT/HTML throw too.
In short:
da;q=abc, a trailing space
and empty list elements (which RFC 9110 requires recipients to accept)
all raise an exception. A * in Accept-Language revives languages ruled
out with q=0. It's git-only and has had no commits since January 2024.*/* picks the server's last choice. It
compares media types case-sensitively.; throws a NullPointerException through its middleware.q=abc and on a parameter without =. Its interceptor sets no Vary.None of them runs in ClojureScript. Only pick and liberator set Vary, and liberator's leaves out Accept-Language when the request has none. The harnesses are in the scratch directory, one per library.
q=1.5, accepts */html, compares every parameter value
without case and allows identity only when Accept-Encoding is missing.en-US;q=0, en still picks en-US. Django drops
q=0 ranges before matching, so text/*, text/plain;q=0 accepts
text/plain. Its locale middleware lets a cookie override Accept-Language
but doesn't add Cookie to Vary.*/* next to other types,
treating it as a browser's. That heuristic stays out.In:
negotiate, which takes what you offer and a Ring request and returns
the choice in each dimension with the fields for VaryPedestal's requests are Ring requests, so negotiate serves both.
Left out:
select-label).Readings where the specs leave a choice:
* matches only tags that no other range
matches. A tag that no range matches takes the weight of a range that
falls back to it by lookup (RFC 4647, 3.4), e.g. da from da-DK. A
fallback loses to a range that names the tag, beats *, and never
comes from a range of weight 0.text/*, text/html picks HTML and */* picks your first offer. For languages,
* ranks below any named range. Among offers that the same range
matches, the tag itself beats a longer tag, which beats a fallback.q=abc, q=1.5, q=.2 or
*/html. A field in which nothing parses counts as absent. An empty
field counts as a list of nothing, so nothing is acceptable. This
agrees with negotiator and Django, and with RFC 9110's explicit rule for
an empty Accept-Encoding. Older JDKs sent *; q=.2, */*; q=.2 by
default, which a strict reading drops. JDK 19 fixed that, with
backports to 11 and 17.Here is the language rule on DanNet's cases. All 65 of negotiator's Accept-Language tuples agree with it too (checked with a prototype):
| Accept-Language | Offers ["da" "en"] | Why |
|---|---|---|
da-DK | da | fallback |
da-DK, en;q=0.8 | da | the fallback has weight 1 |
EN | en | tags ignore case |
da;q=0 | nothing | |
*;q=0.5, da;q=0 | en | * covers the others |
da-DK, da;q=0 | nothing | a named range beats a fallback |
da-DK;q=0.2, *;q=0.9 | en | a fallback beats * |
da-DK, en | da | the client's order |
en, da-DK | en | the client's order |
(require '[dk.simongray.conneg :as conneg])
(conneg/media-type "application/json;q=0.2, text/*;q=0.9"
["text/html" "application/json" "text/turtle"])
;; => "text/html"
(conneg/language "da-DK, en;q=0.8" ["da" "en"])
;; => "da"
Each function returns the offer as you gave it, or nil when nothing is
acceptable. The offers are in your order of preference. As in
langtag-clj, a function that returns the media type or tag of each item
lets you pick items rather than strings, e.g. (conneg/media-type accept :type renderers). In a browser, (conneg/language (vec js/navigator.languages) ["da" "en"]) works too, since a coll of ranges is
a priority list.
To see the preferences as data, use conneg/media-ranges, or langtag's
priority-list for languages:
(conneg/media-ranges "text/*;q=0.3, text/plain;format=flowed")
;; => [{:type "text" :subtype "plain" :parameters {"format" "flowed"}
;; :weight 1.0}
;; {:type "text" :subtype "*" :weight 0.3}]
A media range is a map as mime/parse returns it, plus :weight, in order
of weight.
(def negotiation
{:media-type {:offers ["text/html" "application/ld+json" "text/turtle"]
:query "format"
:aliases {"html" "text/html"
"json-ld" "application/ld+json"
"turtle" "text/turtle"}}
:language {:offers ["da" "en"]
:default "en"
:query "lang"
:cookie "lang"}})
(conneg/negotiate negotiation
{:uri "/synset/1"
:headers {"accept" "text/turtle, */*;q=0.1"
"accept-language" "da-DK"}})
;; => {:media-type "text/turtle"
;; :language "da"
;; :vary ["Accept" "Cookie" "Accept-Language"]}
(conneg/negotiate negotiation
{:uri "/synset/1"
:query-string "format=xml"
:headers {"cookie" "lang=en"}})
;; => {:language "en"
;; :vary ["Cookie"]
;; :not-acceptable #{:media-type}}
The second request asks for a format that isn't offered, so Accept isn't consulted. The cookie decides the language, so Accept-Language isn't either.
Each dimension (:media-type and :language) takes:
The top level takes :vary, the other request fields that your response depends on, e.g. Origin for CORS headers.
A query parameter or cookie takes what the field takes (a media type or a
language priority list) or an alias. The negotiate function reads the
query parameters from :query-string, so it doesn't depend on another
middleware's decoding. It reads cookies from Ring's :cookies when a
middleware has parsed them, or else from the Cookie header.
(def alternates
(conneg/alternates negotiation request))
;; => [{:media-type "text/html" :href "/synset/1?format=html"}
;; {:media-type "application/ld+json" :href "/synset/1?format=json-ld"}
;; {:media-type "text/turtle" :href "/synset/1?format=turtle"}
;; {:language "da" :href "/synset/1?lang=da"}
;; {:language "en" :href "/synset/1?lang=en"}]
(conneg/link-header alternates)
;; => "</synset/1?format=html>; rel=\"alternate\"; type=\"text/html\", ..."
(conneg/not-acceptable decision alternates)
;; => {:status 406
;; :headers {"Content-Type" "text/html;charset=utf-8"
;; "Vary" "Cookie"
;; "Link" "</synset/1?format=html>; ..."}
;; :body "<!DOCTYPE html>..."}
(conneg/see-other "/doc/alice" decision)
;; => {:status 303
;; :headers {"Location" "/doc/alice" "Vary" "Accept"}
;; :body "<!DOCTYPE html>..."}
(conneg/vary-header "Origin" ["Accept" "Cookie"])
;; => "Origin, Accept, Cookie"
The alternates come from the dimensions that have a query parameter: the
URL of the request with that parameter set to an alias of each offer, or
to the offer itself. Other query parameters stay. The 406 lists them as
links in a small HTML page and in a Link header. The 303 body is the short
note with a link that RFC 9110 asks for. The vary-header function merges
field names without case, and returns * alone when either side has it.
(require '[dk.simongray.conneg.ring :as conneg.ring])
(def app
(conneg.ring/wrap-negotiate handler negotiation))
The middleware puts the decision in the request under
::conneg/decision. When nothing is acceptable, it answers 406 itself.
It adds Vary to every response, merged with any Vary that the handler
sets. It takes synchronous and asynchronous handlers.
(require '[dk.simongray.conneg.pedestal :as conneg.pedestal])
(def negotiate-ic
(conneg.pedestal/negotiate negotiation))
The interceptor does the same. On :enter, it puts the decision in the request, or sets a 406 response, which ends the chain as it does for Pedestal's own negotiate-content. On :leave, it adds Vary.
Both take these options:
The adapters leave Content-Type and Content-Language to the handler, which writes the body.
No function throws on a field, query parameter or cookie. A malformed element is skipped, and nil means that nothing is acceptable. An offer that doesn't parse matches nothing.
The decision says which dimensions have nothing acceptable, rather than failing.
All of them run on the JVM and in Node.
A request parses two short fields, so a negotiation should take a few microseconds. A bench like langtag-clj's times typical browser headers against DanNet's seven media types. muuntaja memoizes by the raw field value. That stays out unless the bench shows a need.
This is how the negotiation in resources.clj could be replaced once the
Pedestal upgrade has landed. The fix for ?lang is on master already
(ab732a7).
(def negotiation
{:media-type {:offers ["text/html"
"text/turtle"
"application/edn"
"application/ld+json"
"application/sparql-results+json"
"application/json"
"application/transit+json"]
:query "format"
:aliases {"html" "text/html"
"turtle" "text/turtle"
"ttl" "text/turtle"
"edn" "application/edn"
"json-ld" "application/ld+json"
"json" "application/json"
"transit" "application/transit+json"}}
:language {:offers i18n/supported-languages
:default "en"
:query "lang"
:aliases {"danish" "da" "english" "en"}
:cookie {:name "languages"
:read (comp first edn/read-string)}}
;; pages read other cookies too, and CORS in development reads Origin
:vary (cond-> ["Cookie"] shared/development? (conj "Origin"))})
(def negotiate-ic
(conneg.pedestal/negotiate negotiation))
content-type->body-fn. A vector settles the TODO about that order in
response.clj.(get-in request [:accept :field]) becomes (get-in request [::conneg/decision :media-type]), and request->languages calls
i18n/lang-prefs on the decision's language. The cookie holds the same
vector that lang-prefs returns, so nothing changes for users there.error-content-type call conneg/media-type on
the Accept header instead of looking for "text/html" in it. So
text/html;q=0 no longer counts as a request for HTML.error-ic can add one with
conneg/vary-header from the decision in the request.redirect-ic reads
the media type from the decision. It could use conneg/see-other for
its 303s, but the Transit response stays a 200 with an X-Redirect
header.What changes for users:
?format=xml returns a 406 that lists the formats, instead of a 500.Accept: */*, as curl sends, gets HTML both
before and after.?lang= with a language other than da or en gives English. Today any
well-formed range passes through, e.g. ?lang=de asks for German labels
where the data has them.fallbacks public. It's the shortening step of
lookup (RFC 4647, 3.4), and the language rule needs it for each range.
Everything else it needs is public already: priority-list and
basic-match?.split-values public. It is Fetch's "get, decode,
and split", which splits a field value at commas outside quoted strings.
Accept needs it, since a parameter can hold a quoted comma. Each range
then goes to mime/parse.About 600 lines of source, a little less than langtag-clj. Most of it is
the matching of the two fields and negotiate. The responses and the two
adapters are short.
After the proposal, on 13 October 2026:
dk.simongray/conneg-clj with the
namespace dk.simongray.conneg.fallbacks and
split-values are public in a release, conneg-clj works around them,
with a TODO at each place.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |