Skip to content

Commit 96883fc

Browse files
authored
Merge pull request eXist-db#6477 from joewiz/feature/request-content-negotiation
[feature] request module: Accept-header parsing and content negotiation
2 parents d459e8c + 7db4b3c commit 96883fc

6 files changed

Lines changed: 627 additions & 0 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/*
2+
* eXist-db Open Source Native XML Database
3+
* Copyright (C) 2001 The eXist-db Authors
4+
*
5+
* info@exist-db.org
6+
* http://www.exist-db.org
7+
*
8+
* This library is free software; you can redistribute it and/or
9+
* modify it under the terms of the GNU Lesser General Public
10+
* License as published by the Free Software Foundation; either
11+
* version 2.1 of the License, or (at your option) any later version.
12+
*
13+
* This library is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16+
* Lesser General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Lesser General Public
19+
* License along with this library; if not, write to the Free Software
20+
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
21+
*/
22+
package org.exist.http;
23+
24+
import javax.annotation.Nullable;
25+
import java.util.ArrayList;
26+
import java.util.LinkedHashMap;
27+
import java.util.List;
28+
import java.util.Map;
29+
import java.util.Optional;
30+
31+
/**
32+
* Parsing and proactive content negotiation for the HTTP {@code Accept} header
33+
* (RFC 7231 §5.3.2). Pure and request-independent so the same logic can be
34+
* reused by the REST server, the {@code request} XQuery module, and any other
35+
* caller. Quality values ({@code q=}) and the {@code *}/{@code *} and
36+
* {@code type/*} wildcards are honored.
37+
*/
38+
public final class AcceptHeader {
39+
40+
private AcceptHeader() {
41+
}
42+
43+
/**
44+
* A single media range from an {@code Accept} header, e.g. {@code text/html;q=0.8}.
45+
*
46+
* @param type the primary type ("text"), or "*" for a wildcard
47+
* @param subtype the subtype ("html"), or "*" for a wildcard
48+
* @param quality the quality value (q), from 0.0 to 1.0
49+
* @param parameters media-range parameters other than q, in declaration order
50+
*/
51+
public record MediaRange(String type, String subtype, double quality, Map<String, String> parameters) {
52+
53+
/**
54+
* @return the "type/subtype" media type as a string.
55+
*/
56+
public String mediaType() {
57+
return type + "/" + subtype;
58+
}
59+
60+
private int specificity() {
61+
if ("*".equals(type)) {
62+
return 0;
63+
}
64+
return "*".equals(subtype) ? 1 : 2;
65+
}
66+
67+
private boolean matches(final String otherType, final String otherSubtype) {
68+
final boolean typeMatches = "*".equals(type) || type.equalsIgnoreCase(otherType);
69+
final boolean subtypeMatches = "*".equals(subtype) || subtype.equalsIgnoreCase(otherSubtype);
70+
return typeMatches && subtypeMatches;
71+
}
72+
}
73+
74+
/**
75+
* Parse an {@code Accept} header into its media ranges, ordered by descending
76+
* quality and then descending specificity. Malformed entries are skipped.
77+
* Entries with {@code q=0} are retained, as they signify explicit rejection.
78+
*
79+
* @param header the raw {@code Accept} header value (may be null or empty)
80+
* @return the parsed media ranges, highest preference first
81+
*/
82+
public static List<MediaRange> parse(@Nullable final String header) {
83+
final List<MediaRange> ranges = new ArrayList<>();
84+
if (header == null || header.isBlank()) {
85+
return ranges;
86+
}
87+
for (final String element : header.split(",")) {
88+
final MediaRange range = parseRange(element.trim());
89+
if (range != null) {
90+
ranges.add(range);
91+
}
92+
}
93+
ranges.sort((a, b) -> {
94+
final int byQuality = Double.compare(b.quality(), a.quality());
95+
return byQuality != 0 ? byQuality : Integer.compare(b.specificity(), a.specificity());
96+
});
97+
return ranges;
98+
}
99+
100+
@Nullable
101+
private static MediaRange parseRange(final String element) {
102+
if (element.isEmpty()) {
103+
return null;
104+
}
105+
final String[] parts = element.split(";");
106+
final String mediaType = parts[0].trim();
107+
final int slash = mediaType.indexOf('/');
108+
if (slash < 1 || slash == mediaType.length() - 1) {
109+
return null; // malformed: missing type or subtype
110+
}
111+
final String type = mediaType.substring(0, slash).trim();
112+
final String subtype = mediaType.substring(slash + 1).trim();
113+
double quality = 1.0;
114+
final Map<String, String> parameters = new LinkedHashMap<>();
115+
for (int i = 1; i < parts.length; i++) {
116+
final String param = parts[i].trim();
117+
final int eq = param.indexOf('=');
118+
if (eq < 1) {
119+
continue;
120+
}
121+
final String name = param.substring(0, eq).trim();
122+
final String value = unquote(param.substring(eq + 1).trim());
123+
if ("q".equalsIgnoreCase(name)) {
124+
quality = parseQuality(value);
125+
} else {
126+
parameters.put(name, value);
127+
}
128+
}
129+
return new MediaRange(type, subtype, quality, parameters);
130+
}
131+
132+
private static String unquote(final String value) {
133+
if (value.length() >= 2 && value.charAt(0) == '"' && value.charAt(value.length() - 1) == '"') {
134+
return value.substring(1, value.length() - 1);
135+
}
136+
return value;
137+
}
138+
139+
private static double parseQuality(final String value) {
140+
try {
141+
final double quality = Double.parseDouble(value);
142+
if (quality < 0.0) {
143+
return 0.0;
144+
}
145+
return Math.min(quality, 1.0);
146+
} catch (final NumberFormatException e) {
147+
return 1.0;
148+
}
149+
}
150+
151+
/**
152+
* Negotiate the best media type to return for a request.
153+
*
154+
* Given the media types the server can produce, return the one most preferred
155+
* by the client's {@code Accept} header (RFC 7231 §5.3.2), honoring quality
156+
* values and wildcards. A missing or empty {@code Accept} header (or one that
157+
* accepts everything) means "no preference", so the first offer is returned.
158+
* Returns empty if no offer is acceptable (the caller should then respond with
159+
* 406 Not Acceptable). When several offers tie on quality, the one matching a
160+
* more specific range wins; remaining ties are broken by the order of $available.
161+
*
162+
* @param header the raw {@code Accept} header value (may be null or empty)
163+
* @param available the media types the server can produce, in preference order
164+
* @return the best matching media type, or empty if none is acceptable
165+
*/
166+
public static Optional<String> negotiate(@Nullable final String header, final List<String> available) {
167+
if (available.isEmpty()) {
168+
return Optional.empty();
169+
}
170+
final List<MediaRange> ranges = parse(header);
171+
if (ranges.isEmpty()) {
172+
return Optional.of(available.get(0)); // no (parseable) preference -> first offer
173+
}
174+
175+
String best = null;
176+
double bestQuality = 0.0;
177+
int bestSpecificity = -1;
178+
for (final String offer : available) {
179+
final MediaRange matched = mostSpecificMatch(ranges, offer);
180+
if (matched == null || matched.quality() <= 0.0) {
181+
continue; // not acceptable
182+
}
183+
if (matched.quality() > bestQuality
184+
|| (matched.quality() == bestQuality && matched.specificity() > bestSpecificity)) {
185+
best = offer;
186+
bestQuality = matched.quality();
187+
bestSpecificity = matched.specificity();
188+
}
189+
}
190+
return Optional.ofNullable(best);
191+
}
192+
193+
/**
194+
* Find the most specific media range that matches the given offer.
195+
*
196+
* @param ranges the parsed Accept media ranges
197+
* @param offer a "type/subtype" media type the server can produce
198+
* @return the most specific matching range, or null if none match
199+
*/
200+
@Nullable
201+
private static MediaRange mostSpecificMatch(final List<MediaRange> ranges, final String offer) {
202+
final int slash = offer.indexOf('/');
203+
final String offerType = slash < 0 ? offer : offer.substring(0, slash);
204+
final String offerSubtype = slash < 0 ? "*" : offer.substring(slash + 1);
205+
206+
MediaRange matched = null;
207+
for (final MediaRange range : ranges) {
208+
if (range.matches(offerType, offerSubtype)
209+
&& (matched == null || range.specificity() > matched.specificity())) {
210+
matched = range;
211+
}
212+
}
213+
return matched;
214+
}
215+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* eXist-db Open Source Native XML Database
3+
* Copyright (C) 2001 The eXist-db Authors
4+
*
5+
* info@exist-db.org
6+
* http://www.exist-db.org
7+
*
8+
* This library is free software; you can redistribute it and/or
9+
* modify it under the terms of the GNU Lesser General Public
10+
* License as published by the Free Software Foundation; either
11+
* version 2.1 of the License, or (at your option) any later version.
12+
*
13+
* This library is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16+
* Lesser General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Lesser General Public
19+
* License along with this library; if not, write to the Free Software
20+
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
21+
*/
22+
package org.exist.xquery.functions.request;
23+
24+
import org.exist.dom.QName;
25+
import org.exist.http.AcceptHeader;
26+
import org.exist.http.servlets.RequestWrapper;
27+
import org.exist.xquery.Cardinality;
28+
import org.exist.xquery.FunctionSignature;
29+
import org.exist.xquery.XPathException;
30+
import org.exist.xquery.XQueryContext;
31+
import org.exist.xquery.value.FunctionParameterSequenceType;
32+
import org.exist.xquery.value.FunctionReturnSequenceType;
33+
import org.exist.xquery.value.Sequence;
34+
import org.exist.xquery.value.SequenceIterator;
35+
import org.exist.xquery.value.SequenceType;
36+
import org.exist.xquery.value.StringValue;
37+
import org.exist.xquery.value.Type;
38+
39+
import javax.annotation.Nonnull;
40+
import java.util.ArrayList;
41+
import java.util.List;
42+
import java.util.Optional;
43+
44+
/**
45+
* Implements the {@code request:negotiate-content-type} function, which selects
46+
* the best media type to return for the current request by matching the media
47+
* types the server can produce against the HTTP {@code Accept} header.
48+
*/
49+
public class NegotiateContentType extends StrictRequestFunction {
50+
51+
private static final String FN_NAME = "negotiate-content-type";
52+
53+
private static final FunctionParameterSequenceType FS_PARAM_AVAILABLE = new FunctionParameterSequenceType(
54+
"available", Type.STRING, Cardinality.ZERO_OR_MORE,
55+
"The media types the server can produce, in order of preference.");
56+
private static final FunctionParameterSequenceType FS_PARAM_DEFAULT = new FunctionParameterSequenceType(
57+
"default", Type.STRING, Cardinality.ZERO_OR_ONE,
58+
"The media type to fall back to when no item of $available is acceptable.");
59+
60+
private static final String DESCRIPTION =
61+
"Selects the best media type to return for the current request by matching the media types the "
62+
+ "server can produce ($available) against the HTTP Accept header of the request. Quality "
63+
+ "values (q=) and the */* and type/* wildcards are honored, per RFC 7231. A missing or empty "
64+
+ "Accept header means no preference, in which case the first item of $available is returned.";
65+
66+
public static final FunctionSignature[] signatures = {
67+
new FunctionSignature(
68+
new QName(FN_NAME, RequestModule.NAMESPACE_URI, RequestModule.PREFIX),
69+
DESCRIPTION + " Returns the empty sequence if no item of $available is acceptable; the caller "
70+
+ "should then respond with 406 Not Acceptable.",
71+
new SequenceType[] { FS_PARAM_AVAILABLE },
72+
new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_ONE,
73+
"the best matching media type, or the empty sequence if none is acceptable")),
74+
new FunctionSignature(
75+
new QName(FN_NAME, RequestModule.NAMESPACE_URI, RequestModule.PREFIX),
76+
DESCRIPTION + " Returns $default if no item of $available is acceptable.",
77+
new SequenceType[] { FS_PARAM_AVAILABLE, FS_PARAM_DEFAULT },
78+
new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_ONE,
79+
"the best matching media type, or $default if none is acceptable"))
80+
};
81+
82+
public NegotiateContentType(final XQueryContext context, final FunctionSignature signature) {
83+
super(context, signature);
84+
}
85+
86+
@Override
87+
public Sequence eval(final Sequence[] args, @Nonnull final RequestWrapper request) throws XPathException {
88+
final List<String> available = new ArrayList<>(args[0].getItemCount());
89+
for (final SequenceIterator i = args[0].iterate(); i.hasNext(); ) {
90+
available.add(i.nextItem().getStringValue());
91+
}
92+
93+
final Optional<String> best = AcceptHeader.negotiate(request.getHeader("Accept"), available);
94+
if (best.isPresent()) {
95+
return new StringValue(this, best.get());
96+
}
97+
if (args.length > 1) {
98+
return args[1];
99+
}
100+
return Sequence.EMPTY_SEQUENCE;
101+
}
102+
}

0 commit comments

Comments
 (0)