Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 215 additions & 0 deletions exist-core/src/main/java/org/exist/http/AcceptHeader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/*
* eXist-db Open Source Native XML Database
* Copyright (C) 2001 The eXist-db Authors
*
* info@exist-db.org
* http://www.exist-db.org
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
package org.exist.http;

import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
* Parsing and proactive content negotiation for the HTTP {@code Accept} header
* (RFC 7231 §5.3.2). Pure and request-independent so the same logic can be
* reused by the REST server, the {@code request} XQuery module, and any other
* caller. Quality values ({@code q=}) and the {@code *}/{@code *} and
* {@code type/*} wildcards are honored.
*/
public final class AcceptHeader {

private AcceptHeader() {
}

/**
* A single media range from an {@code Accept} header, e.g. {@code text/html;q=0.8}.
*
* @param type the primary type ("text"), or "*" for a wildcard
* @param subtype the subtype ("html"), or "*" for a wildcard
* @param quality the quality value (q), from 0.0 to 1.0
* @param parameters media-range parameters other than q, in declaration order
*/
public record MediaRange(String type, String subtype, double quality, Map<String, String> parameters) {

/**
* @return the "type/subtype" media type as a string.
*/
public String mediaType() {
return type + "/" + subtype;
}

private int specificity() {
if ("*".equals(type)) {
return 0;
}
return "*".equals(subtype) ? 1 : 2;
}

private boolean matches(final String otherType, final String otherSubtype) {
final boolean typeMatches = "*".equals(type) || type.equalsIgnoreCase(otherType);
final boolean subtypeMatches = "*".equals(subtype) || subtype.equalsIgnoreCase(otherSubtype);
return typeMatches && subtypeMatches;
}
}

/**
* Parse an {@code Accept} header into its media ranges, ordered by descending
* quality and then descending specificity. Malformed entries are skipped.
* Entries with {@code q=0} are retained, as they signify explicit rejection.
*
* @param header the raw {@code Accept} header value (may be null or empty)
* @return the parsed media ranges, highest preference first
*/
public static List<MediaRange> parse(@Nullable final String header) {
final List<MediaRange> ranges = new ArrayList<>();
if (header == null || header.isBlank()) {
return ranges;
}
for (final String element : header.split(",")) {
final MediaRange range = parseRange(element.trim());
if (range != null) {
ranges.add(range);
}
}
ranges.sort((a, b) -> {
final int byQuality = Double.compare(b.quality(), a.quality());
return byQuality != 0 ? byQuality : Integer.compare(b.specificity(), a.specificity());
});
return ranges;
}

@Nullable
private static MediaRange parseRange(final String element) {
if (element.isEmpty()) {
return null;
}
final String[] parts = element.split(";");
final String mediaType = parts[0].trim();
final int slash = mediaType.indexOf('/');
if (slash < 1 || slash == mediaType.length() - 1) {
return null; // malformed: missing type or subtype
}
final String type = mediaType.substring(0, slash).trim();
final String subtype = mediaType.substring(slash + 1).trim();
double quality = 1.0;
final Map<String, String> parameters = new LinkedHashMap<>();
for (int i = 1; i < parts.length; i++) {
final String param = parts[i].trim();
final int eq = param.indexOf('=');
if (eq < 1) {
continue;
}
final String name = param.substring(0, eq).trim();
final String value = unquote(param.substring(eq + 1).trim());
if ("q".equalsIgnoreCase(name)) {
quality = parseQuality(value);
} else {
parameters.put(name, value);
}
}
return new MediaRange(type, subtype, quality, parameters);
}

private static String unquote(final String value) {
if (value.length() >= 2 && value.charAt(0) == '"' && value.charAt(value.length() - 1) == '"') {
return value.substring(1, value.length() - 1);
}
return value;
}

private static double parseQuality(final String value) {
try {
final double quality = Double.parseDouble(value);
if (quality < 0.0) {
return 0.0;
}
return Math.min(quality, 1.0);
} catch (final NumberFormatException e) {
return 1.0;
}
}

/**
* Negotiate the best media type to return for a request.
*
* Given the media types the server can produce, return the one most preferred
* by the client's {@code Accept} header (RFC 7231 §5.3.2), honoring quality
* values and wildcards. A missing or empty {@code Accept} header (or one that
* accepts everything) means "no preference", so the first offer is returned.
* Returns empty if no offer is acceptable (the caller should then respond with
* 406 Not Acceptable). When several offers tie on quality, the one matching a
* more specific range wins; remaining ties are broken by the order of $available.
*
* @param header the raw {@code Accept} header value (may be null or empty)
* @param available the media types the server can produce, in preference order
* @return the best matching media type, or empty if none is acceptable
*/
public static Optional<String> negotiate(@Nullable final String header, final List<String> available) {
if (available.isEmpty()) {
return Optional.empty();
}
final List<MediaRange> ranges = parse(header);
if (ranges.isEmpty()) {
return Optional.of(available.get(0)); // no (parseable) preference -> first offer
}

String best = null;
double bestQuality = 0.0;
int bestSpecificity = -1;
for (final String offer : available) {
final MediaRange matched = mostSpecificMatch(ranges, offer);
if (matched == null || matched.quality() <= 0.0) {
continue; // not acceptable
}
if (matched.quality() > bestQuality
|| (matched.quality() == bestQuality && matched.specificity() > bestSpecificity)) {
best = offer;
bestQuality = matched.quality();
bestSpecificity = matched.specificity();
}
}
return Optional.ofNullable(best);
}

/**
* Find the most specific media range that matches the given offer.
*
* @param ranges the parsed Accept media ranges
* @param offer a "type/subtype" media type the server can produce
* @return the most specific matching range, or null if none match
*/
@Nullable
private static MediaRange mostSpecificMatch(final List<MediaRange> ranges, final String offer) {
final int slash = offer.indexOf('/');
final String offerType = slash < 0 ? offer : offer.substring(0, slash);
final String offerSubtype = slash < 0 ? "*" : offer.substring(slash + 1);

MediaRange matched = null;
for (final MediaRange range : ranges) {
if (range.matches(offerType, offerSubtype)
&& (matched == null || range.specificity() > matched.specificity())) {
matched = range;
}
}
return matched;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* eXist-db Open Source Native XML Database
* Copyright (C) 2001 The eXist-db Authors
*
* info@exist-db.org
* http://www.exist-db.org
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
package org.exist.xquery.functions.request;

import org.exist.dom.QName;
import org.exist.http.AcceptHeader;
import org.exist.http.servlets.RequestWrapper;
import org.exist.xquery.Cardinality;
import org.exist.xquery.FunctionSignature;
import org.exist.xquery.XPathException;
import org.exist.xquery.XQueryContext;
import org.exist.xquery.value.FunctionParameterSequenceType;
import org.exist.xquery.value.FunctionReturnSequenceType;
import org.exist.xquery.value.Sequence;
import org.exist.xquery.value.SequenceIterator;
import org.exist.xquery.value.SequenceType;
import org.exist.xquery.value.StringValue;
import org.exist.xquery.value.Type;

import javax.annotation.Nonnull;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

/**
* Implements the {@code request:negotiate-content-type} function, which selects
* the best media type to return for the current request by matching the media
* types the server can produce against the HTTP {@code Accept} header.
*/
public class NegotiateContentType extends StrictRequestFunction {

private static final String FN_NAME = "negotiate-content-type";

private static final FunctionParameterSequenceType FS_PARAM_AVAILABLE = new FunctionParameterSequenceType(
"available", Type.STRING, Cardinality.ZERO_OR_MORE,
"The media types the server can produce, in order of preference.");
private static final FunctionParameterSequenceType FS_PARAM_DEFAULT = new FunctionParameterSequenceType(
"default", Type.STRING, Cardinality.ZERO_OR_ONE,
"The media type to fall back to when no item of $available is acceptable.");

private static final String DESCRIPTION =
"Selects the best media type to return for the current request by matching the media types the "
+ "server can produce ($available) against the HTTP Accept header of the request. Quality "
+ "values (q=) and the */* and type/* wildcards are honored, per RFC 7231. A missing or empty "
+ "Accept header means no preference, in which case the first item of $available is returned.";

public static final FunctionSignature[] signatures = {
new FunctionSignature(
new QName(FN_NAME, RequestModule.NAMESPACE_URI, RequestModule.PREFIX),
DESCRIPTION + " Returns the empty sequence if no item of $available is acceptable; the caller "
+ "should then respond with 406 Not Acceptable.",
new SequenceType[] { FS_PARAM_AVAILABLE },
new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_ONE,
"the best matching media type, or the empty sequence if none is acceptable")),
new FunctionSignature(
new QName(FN_NAME, RequestModule.NAMESPACE_URI, RequestModule.PREFIX),
DESCRIPTION + " Returns $default if no item of $available is acceptable.",
new SequenceType[] { FS_PARAM_AVAILABLE, FS_PARAM_DEFAULT },
new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_ONE,
"the best matching media type, or $default if none is acceptable"))
};

public NegotiateContentType(final XQueryContext context, final FunctionSignature signature) {
super(context, signature);
}

@Override
public Sequence eval(final Sequence[] args, @Nonnull final RequestWrapper request) throws XPathException {
final List<String> available = new ArrayList<>(args[0].getItemCount());
for (final SequenceIterator i = args[0].iterate(); i.hasNext(); ) {
available.add(i.nextItem().getStringValue());
}

final Optional<String> best = AcceptHeader.negotiate(request.getHeader("Accept"), available);
if (best.isPresent()) {
return new StringValue(this, best.get());
}
if (args.length > 1) {
return args[1];
}
return Sequence.EMPTY_SEQUENCE;
}
}
Loading