Skip to content

[feature] request module: Accept-header parsing and content negotiation#6477

Merged
duncdrum merged 1 commit into
eXist-db:developfrom
joewiz:feature/request-content-negotiation
Jun 16, 2026
Merged

[feature] request module: Accept-header parsing and content negotiation#6477
duncdrum merged 1 commit into
eXist-db:developfrom
joewiz:feature/request-content-negotiation

Conversation

@joewiz

@joewiz joewiz commented Jun 15, 2026

Copy link
Copy Markdown
Member

[This PR was co-authored with Claude Code. -Joe]

Summary

Adds reusable HTTP content negotiation to the request module (http://exist-db.org/xquery/request), so applications no longer have to hand-roll Accept-header parsing:

request:negotiate-content-type($available as xs:string*) as xs:string?
request:negotiate-content-type($available as xs:string*, $default as xs:string?) as xs:string?
request:parse-accept-header() as map(*)*

Motivation

Content negotiation is currently left to each app developer. The request module exposes only the raw header (request:get-header("Accept")) — no quality-value parsing, no media-type matching — and the REST server does no Accept negotiation at all. This surfaced while reviewing the eXide login PR (eXist-db/eXide#801): the underlying gap is that there's no shared primitive, so every consumer either ignores Accept or re-derives a partial parser.

Notably, no XQuery platform exposes a q-weighted negotiation primitive — the RESTXQ spec stops at "absolute before wildcard" specificity, the request module and EXQuery have nothing, and BaseX's q-factor handling is an extension inside its own RESTXQ engine. The function shape here follows the convergent practice of mature HTTP frameworks: JAX-RS Request.selectVariant, Spring MediaType, Node's negotiator, Werkzeug's MIMEAccept.best_match, and Go's httputil.NegotiateContentType — all "give the server's offers, get the best match, or nothing → 406."

What the functions do

request:negotiate-content-type($available[, $default]) — selects the best media type the server can produce for the current request's Accept header (RFC 7231 §5.3.2: quality values and */* / type/* wildcards). An empty or absent Accept means "no preference," so the first offer wins. Returns the empty sequence — or $default, in the 2-arg form — when nothing is acceptable, so the caller can respond 406 Not Acceptable.

(: Accept: text/html, application/json;q=0.9, */*;q=0.8 :)
request:negotiate-content-type(("application/json", "application/xml"))   (: => "application/json" :)

(: Accept: application/json :)
request:negotiate-content-type(("application/xml"))                       (: => ()  -> caller sends 406 :)
request:negotiate-content-type(("application/xml"), "application/xml")    (: => "application/xml" :)

request:parse-accept-header() — parses the Accept header into a quality-ranked sequence of maps, for callers that want to build their own logic:

(: Accept: text/html;level=1, application/json;q=0.9 :)
request:parse-accept-header()
(: => ( map { "type": "text/html",       "quality": 1.0e0, "parameters": map { "level": "1" } },
        map { "type": "application/json", "quality": 0.9e0, "parameters": map { } } ) :)

Implementation

The parsing and negotiation logic lives in a pure, request-independent helper (org.exist.http.AcceptHeader), so the REST server, Roaster, and any other caller can reuse the exact same algorithm; the two XQuery functions are thin request-bound wrappers. A follow-up will have Roaster adopt this in place of its partial hand-rolled negotiation.

Tests

  • AcceptHeaderTest — 15 unit cases over parse (ordering by quality then specificity, q defaulting, parameters, q=0 retention, malformed/empty input) and negotiate (highest-quality pick, exact-over-wildcard, q=0 rejection, type/* wildcards, no-Accept/*/* → first offer, no-match → empty, tie-break by offer order).
  • RESTServiceTest — 2 integration cases exercising both functions end-to-end over HTTP with a real Accept header.

Related

Separate small PR fixes an eXist-core bug this work brushed against: RESTServer.writeResultJSON never set Content-Type (so output:media-type was ignored for JSON REST results). That one stands alone.

@line-o line-o left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Clean - no notes.

@line-o line-o requested a review from a team June 15, 2026 09:01
line-o pushed a commit that referenced this pull request Jun 15, 2026
Adds a "Choosing XQSuite vs Java tests" subsection to the testing conventions:
default to XQSuite for XQuery-level behavior, and reach for Java only when XQSuite
structurally can't express or exercise the behavior (pure-Java units, request/
response/session context that needs a live HTTP request, the HTTP/transport layer
itself, or Java-level wiring such as broker pool / locking / transactions). Within
Java, use the lightest vehicle that exercises the real behavior. Cites the
request-module content-negotiation work (#6477) as precedent.

AGENTS.md is the canonical, repo-rooted home for this guidance so it is visible to
sessions regardless of which repo they are rooted in.

Also removes the "Known Issues" section, whose three entries were all stale; two
were never true:
- groupby.collation "flaky" / ArrayIndexOutOfBoundsException: unsubstantiated. No
  issue, PR, commit, or CI evidence backs it; the test is deterministic and passes.
  Asserting an unbacked "known flake" risks agents dismissing real CI failures.
- fn:filter / issue #3382: fixed. #3382 is closed and fn:filter now raises XPTY0004
  when the predicate function does not return xs:boolean.
- fn:doc() file:// restriction: unsubstantiated. DocUtils already routed file:/URL
  paths through SourceFactory when this entry was added (2026-03-15), so fn:doc could
  load file: documents all along; the later #6207 work only added security-gating, it
  did not lift a block that never existed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add two functions to the request module (http://exist-db.org/xquery/request)
so apps no longer have to hand-roll HTTP content negotiation:

  request:negotiate-content-type($available as xs:string*) as xs:string?
  request:negotiate-content-type($available as xs:string*,
                                 $default as xs:string?) as xs:string?
    Selects the best media type the server can produce for the request's
    Accept header (RFC 7231 5.3.2: quality values + */* and type/*
    wildcards). An empty/absent Accept means no preference -> first offer.
    Returns the empty sequence (or $default) when nothing is acceptable,
    so the caller can respond 406 Not Acceptable.

  request:parse-accept-header() as map(*)*
    Parses the Accept header into a quality-ranked sequence of maps
    { "type", "quality", "parameters" }.

The parsing and negotiation logic lives in a pure, request-independent
helper (org.exist.http.AcceptHeader) so the REST server, Roaster, and
other callers can reuse the same algorithm. No XQuery platform (RESTXQ,
the request module, EXQuery) currently exposes a q-weighted negotiation
primitive; the function shape follows the convergent practice in JAX-RS,
Spring, Node's negotiator, Werkzeug, and Go's httputil.

Tests: AcceptHeaderTest (15 unit cases over parse + negotiate) and two
RESTServiceTest integration cases exercising the functions over HTTP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@joewiz joewiz force-pushed the feature/request-content-negotiation branch from 5a8052d to 7db4b3c Compare June 16, 2026 03:58
@duncdrum duncdrum merged commit 96883fc into eXist-db:develop Jun 16, 2026
9 checks passed
duncdrum added a commit to duncdrum/exist that referenced this pull request Jun 20, 2026
…negotiation

[feature] request module: Accept-header parsing and content negotiation
duncdrum added a commit to duncdrum/exist that referenced this pull request Jun 20, 2026
…negotiation

[feature] request module: Accept-header parsing and content negotiation
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.

3 participants