This library implements proactive content negotiation (RFC 9110) in Clojure and ClojureScript. Use it to pick a response's media type and language from a request's Accept and Accept-Language headers, and to fill in the matching Vary header.
text/*, text/html;q=0 rules out HTML but
accepts other text. A weight of 0 always rules an offer out.This library was developed with assistance from frontier LLMs.
The library needs Clojure 1.11+ and Java 11+. Add the latest release from
Clojars to the :deps in
your deps.edn:
dk.simongray/conneg-clj {:mvn/version "0.1.0"}
For unreleased changes, use the SHA of the latest commit on master
instead. In shadow-cljs, a Git dependency only works through deps.edn,
so set :deps true in your shadow-cljs.edn too.
To pick a response's media type, pass the Accept header and your offers,
in order of preference, to conneg/media-type:
(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/media-type "text/*, text/html;q=0" ["text/html" "text/turtle"])
;; => "text/turtle"
When nothing is acceptable, e.g. for image/*, it returns nil, and you
can answer 406 or fall back to a default of your own. Without an Accept
header, the client has no preference, so your first offer wins.
At the same weight, the more specific range wins, then the range that the
client lists first, then the offer that comes first. So */* gets your
first offer. A range with parameters only matches offers with the same
parameter values, e.g. text/html;level=1 doesn't match text/html.
To pick something other than a string, pass a function that returns the media type of each item:
(conneg/media-type "text/turtle, */*;q=0.1"
:type
[{:type "text/html" :name :page}
{:type "text/turtle" :name :rdf}])
;; => {:type "text/turtle" :name :rdf}
To parse an Accept header into media ranges, use conneg/media-ranges:
(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}]
Languages work the same way, with conneg/language:
(conneg/language "da-DK, en;q=0.8" ["da" "en"])
;; => "da"
(conneg/language "*;q=0.5, da;q=0" ["da" "en"])
;; => "en"
A range matches every tag that starts with it, so en matches en-GB. A tag
that no range matches can still match a longer range by dropping the
range's last subtags, as RFC 4647's lookup does: da-DK falls back to da,
and zh-Hant-TW to zh-Hant and then zh. The range * only covers tags
that no other range matches.
The function also takes a coll of ranges, e.g. js/navigator.languages
in a browser. To parse an Accept-Language header into language ranges, use
conneg/language-ranges. For other ways to match language tags, see
langtag-clj.
To negotiate a whole Ring request, pass conneg/negotiate what you offer
in each dimension (media type and language) and the request. Pedestal
requests are Ring requests, so they work too:
(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 "/word/1"
:headers {"accept" "text/turtle, */*;q=0.1"
"accept-language" "da-DK"}})
;; => {:media-type "text/turtle"
;; :language "da"
;; :preferences {:media-type "text/turtle, */*;q=0.1" :language "da-DK"}
;; :vary ["Accept" "Cookie" "Accept-Language"]}
A query parameter such as ?format=turtle or ?lang=da-DK overrides the header, and so does a cookie when there's no query parameter. Each can hold an alias or the same kind of value as the header, e.g. turtle or text/turtle. For a cookie in a format of your own, make :cookie a map of its :name and a :read function that parses the value.
The decision's :vary lists the headers that the choice depended on, even
missing ones, so put it in your response's Vary header. A query parameter
is part of the URL, so it never goes in Vary. When a cookie decides, the
header that it overrides doesn't go in either. To add headers of your
own, e.g. Origin, give the negotiation a :vary. To merge Vary values, use
conneg/vary-header.
The decision's :preferences holds what decided each dimension, also when
no offer matched it, e.g. de for ?lang=de. To choose other things by the
same preference, pass it to conneg/language or conneg/media-type, e.g.
to show German labels wherever a page has them.
When nothing is acceptable in a dimension without a :default, the
decision lists that dimension under :not-acceptable. A query parameter
that matches no offer, e.g. ?format=xml, counts as nothing acceptable,
but a cookie that matches no offer counts as missing. Answer with
conneg/not-acceptable, which lists the alternatives:
(let [request {:uri "/word/1" :query-string "format=xml"}
decision (conneg/negotiate negotiation request)]
(conneg/not-acceptable decision (conneg/alternates negotiation request)))
;; => {:status 406
;; :headers {"Content-Type" "text/html;charset=utf-8"
;; "Vary" "Cookie, Accept-Language"
;; "Link" "</word/1?format=html>; rel=\"alternate\"; ..."}
;; :body "<!DOCTYPE html>..."}
The alternates are your offers, each with a URL when its dimension has a query parameter. Here the page and the Link header only list media types, since the language was acceptable.
The Ring middleware negotiates each request before your handler gets it:
(require '[dk.simongray.conneg.ring :as conneg.ring])
(defn handler
"Answer with the negotiated language, in the negotiated media type."
[{::conneg/keys [decision]}]
{:status 200
:headers {"Content-Type" (:media-type decision)}
:body (str "Language: " (:language decision))})
(def app
(conneg.ring/wrap-negotiate handler negotiation))
(app {:uri "/word/1"
:headers {"accept" "text/turtle" "accept-language" "da"}})
;; => {:status 200
;; :headers {"Content-Type" "text/turtle"
;; "Vary" "Accept, Cookie, Accept-Language"}
;; :body "Language: da"}
The decision is in the request, under :dk.simongray.conneg/decision. When nothing is acceptable, the middleware returns a 406 without calling your handler. It adds the decision's :vary to the Vary header of every response, keeping any that your handler sets. It works with both synchronous and asynchronous handlers.
For Pedestal, put the interceptor in a route:
(require '[dk.simongray.conneg.pedestal :as conneg.pedestal])
(def routes
#{["/word/:id" :get [(conneg.pedestal/negotiate negotiation) word-handler]
:route-name ::word]})
On enter, the interceptor puts the decision in the request, or sets a 406
response that ends the chain. On leave, it adds to the Vary header. The
interceptor is a map, so the library doesn't depend on Pedestal. Outside
a route, pass it to io.pedestal.interceptor/interceptor first.
Both take a :not-acceptable option: a function of the request and the decision that returns your own response when nothing is acceptable.
In Cool URIs for the Semantic Web, a
request for a thing that isn't a document, e.g. a person, gets a 303 See
Other to a document about it. To answer that way, use conneg/see-other,
which adds the decision's Vary, since the location may depend on it:
(def thing
(conneg.ring/wrap-negotiate
(fn [{::conneg/keys [decision]}]
(conneg/see-other (if (= "text/html" (:media-type decision))
"/people/alice"
"/data/alice")
decision))
{:media-type {:offers ["text/html" "application/rdf+xml"]}}))
(thing {:uri "/id/alice" :headers {"accept" "application/rdf+xml"}})
;; => {:status 303
;; :headers {"Location" "/data/alice"
;; "Content-Type" "text/html;charset=utf-8"
;; "Vary" "Accept"}
;; :body "<!DOCTYPE html>..."}
The W3C's rule behind this, the httpRange-14 resolution of 2005, still stands, though many publishers answer such URIs with a 200 instead.
For the documents themselves, the middleware and the interceptor take two more options:
(def app
(conneg.ring/wrap-negotiate handler negotiation {:link? true
:content-location? true}))
(:headers (app {:request-method :get
:uri "/word/1"
:headers {"accept" "text/turtle" "accept-language" "da"}}))
;; => {"Content-Type" "text/turtle"
;; "Vary" "Accept, Cookie, Accept-Language"
;; "Link" "</word/1?format=html>; rel=\"alternate\"; ..."
;; "Content-Location" "/word/1?format=turtle&lang=da"}
Only successful responses to GET and HEAD get these headers. To build
them yourself, use conneg/alternates, conneg/link-header and
conneg/content-location.
By default, the library builds these URLs by setting query parameters on the request's URL, so each dimension needs a :query. For URLs of your own, e.g. a file for each representation as in Cool URIs, give the negotiation an :href: a function of the request and a map of dimension to offer that returns the URL of that representation, or nil. For an alternate, the map holds one dimension, and for Content-Location it holds all of them:
(def extensions
{"text/html" "html" "application/rdf+xml" "rdf"})
(defn file-href
"The URL of the file for the `request` with the `offers`, e.g.
/doc/alice.en.html."
[{:keys [uri] :as request} {:keys [media-type language] :as offers}]
(str uri
(when language (str "." language))
(when media-type (str "." (extensions media-type)))))
(conneg/alternates {:media-type {:offers ["text/html" "application/rdf+xml"]}
:href file-href}
{:uri "/doc/alice"})
;; => [{:media-type "text/html" :href "/doc/alice.html"}
;; {:media-type "application/rdf+xml" :href "/doc/alice.rdf"}]
To change only some of the default URLs, call conneg/query-href from
your :href, and return nil for a representation that you don't want to
list. The middleware and the interceptor pass :href the request with the
decision in it, e.g. to leave out the language for RDF, which is the same
in every language.
*.text/html;q=abc or text/html;q=1.5. When nothing in a header
parses, the header counts as missing. An empty header accepts nothing.Here is what each library picks from ["text/html" "application/json" "text/turtle"] for Accept and from ["da" "en"] for Accept-Language
(with juxt/pick at commit dabc8c4, liberator 0.15.3,
ring-middleware-accept 2.0.3 and Pedestal 0.8.2). Wrong results are in
bold:
| Header | conneg-clj | juxt/pick | liberator | ring-middleware-accept | Pedestal |
|---|---|---|---|---|---|
application/json;q=0.2, text/*;q=0.9 | text/html | text/html | text/html | text/turtle | application/json |
text/html;q=0 | nothing | nothing | text/html | nothing | text/html |
text/*, text/html;q=0 | text/turtle | text/turtle | text/html | text/turtle | text/html |
TEXT/HTML | text/html | text/html | nothing | nothing | nothing |
*/* | text/html | text/html | text/html | text/turtle | text/html |
@@@,,,;;; | text/html | text/html | nothing | nothing | throws |
Accept-Language da-DK | da | nothing | nothing | da | n/a |
*;q=0.5, da;q=0 | en | da | nothing | en | n/a |
da;q=abc | da | throws | da | da | n/a |
Where offers tie, e.g. for */*, ring-middleware-accept doesn't follow
your order of preference. RFC 9110 allows that, so it isn't in bold.
Pedestal only negotiates media types, and muuntaja only matches exact types. None of these libraries runs in ClojureScript.
.cljc and depends on
langtag-clj for language
ranges, whatwg-mime for
media types and whatwg-url
for query strings.clojure -X:test # the tests on the JVM
clojure -X:test:pedestal # also in Pedestal's chain
npm install # once, for the Node tests
clojure -M:cljs compile test # the tests in Node
clojure -M:bench -m dk.simongray.conneg.bench # benchmarks on the JVM
clojure -M:cljs release bench && node target/bench.js # benchmarks in Node
dev/negotiator.sh # records negotiator's tests again
The :pedestal alias also runs the interceptor in Pedestal 0.8's chain,
which needs Java 17. The jshttp/negotiator cases come from its test suite
at a pinned version, which dev/negotiator.sh runs and records. They
keep negotiator's MIT notice.
The public namespaces are the root, ring and pedestal. The internal
ones are under impl:
impl.syntax, RFC 9110's lists and weightsimpl.media, media ranges and how they matchimpl.language, how language ranges match tagsimpl.ranking, how the acceptable offers rankimpl.request, a request's query parameters and cookiesimpl.negotiation, the decision for a request and its alternatesimpl.response, Vary, Link and the 303 and 406 responsesimpl.adapter, what the middleware and the interceptor shareThe conneg-clj project is licensed under the MIT licence.
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 |