diff --git a/webapp/src/edu/cornell/mannlib/vitro/webapp/utils/http/AcceptHeaderParsingException.java b/webapp/src/edu/cornell/mannlib/vitro/webapp/utils/http/AcceptHeaderParsingException.java new file mode 100644 index 000000000..afa2b8615 --- /dev/null +++ b/webapp/src/edu/cornell/mannlib/vitro/webapp/utils/http/AcceptHeaderParsingException.java @@ -0,0 +1,18 @@ +/* $This file is distributed under the terms of the license in /doc/license.txt$ */ + +package edu.cornell.mannlib.vitro.webapp.utils.http; + +/** + * Indicates an invalid Accept header. Either the basic syntax was flawed, or + * the value for "q" could not be parsed to a Float. + */ +public class AcceptHeaderParsingException extends Exception { + public AcceptHeaderParsingException(String message) { + super(message); + } + + public AcceptHeaderParsingException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/webapp/src/edu/cornell/mannlib/vitro/webapp/utils/http/ContentTypeUtil.java b/webapp/src/edu/cornell/mannlib/vitro/webapp/utils/http/ContentTypeUtil.java new file mode 100644 index 000000000..7292c5524 --- /dev/null +++ b/webapp/src/edu/cornell/mannlib/vitro/webapp/utils/http/ContentTypeUtil.java @@ -0,0 +1,241 @@ +/* $This file is distributed under the terms of the license in /doc/license.txt$ */ + +package edu.cornell.mannlib.vitro.webapp.utils.http; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.apache.http.HeaderElement; +import org.apache.http.NameValuePair; +import org.apache.http.message.BasicHeaderValueParser; + +/** + * A utility for selecting content types, in the context of the Accept header. + * + * ------------------- + * + * This does not support matching against content types with extensions, like + * "level=1", as illustrated in RFC-2616: + * http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1 + * + * However, as long as we don't offer such extensions on our available types, + * the use of extensions in the Accept header is moot. + */ +public class ContentTypeUtil { + + /** + * Given an Accept header value and a list of available type names, decide + * which type is the best fit. + * + * If there is no fit, throw a NotAcceptableException + * + * The only thing to do is to match all available against all acceptable, + * and pick the best match. Try to do as little work as possible inside the + * nested loop. + */ + public static String bestContentType(String acceptHeader, + Collection availableTypeNames) + throws AcceptHeaderParsingException, NotAcceptableException { + if (availableTypeNames == null) { + throw new NotAcceptableException("availableTypeNames may not be null."); + } + + Set acceptableTypes = parseAcceptHeader(acceptHeader); + List availableTypes = convertToMatchCriteria(availableTypeNames); + + float bestFitQuality = 0.0F; + MatchCriteria bestMatch = null; + + for (AcceptableType acceptableType : acceptableTypes) { + for (MatchCriteria availableType : availableTypes) { + float fitQuality = acceptableType.fitQuality(availableType); + if (fitQuality > bestFitQuality) { + bestFitQuality = fitQuality; + bestMatch = availableType; + } + } + } + + if (bestMatch == null) { + throw new NotAcceptableException( + "No available type matches the Accept header: " + + acceptHeader); + } else { + return bestMatch.getName(); + } + } + + /** + * The order of items in the Accept header is not important. We rely on the + * specificity of the match and the "q" factor, in that order. + * + * Since q ranges between 1.0 and 0.001, we add a specificity offset of 2, 3 + * or 4. That way, matches with equal specificity are decided by q factor. + */ + public static Set parseAcceptHeader(String acceptHeader) + throws AcceptHeaderParsingException { + if (acceptHeader == null || acceptHeader.trim().isEmpty()) { + return Collections.singleton(new AcceptableType("*/*", "1.0")); + } + + HeaderElement[] elements = BasicHeaderValueParser.parseElements( + acceptHeader, null); + + Set acceptableTypes = new HashSet<>(); + for (HeaderElement he : elements) { + String name = he.getName(); + + NameValuePair qPair = he.getParameterByName("q"); + String qString = (qPair == null) ? "1.0" : qPair.getValue(); + + acceptableTypes.add(new AcceptableType(name, qString)); + } + + return acceptableTypes; + } + + private static List convertToMatchCriteria( + Collection availableTypes) { + List availableMatches = new ArrayList<>(); + for (String availableType : availableTypes) { + availableMatches.add(new MatchCriteria(availableType)); + } + return availableMatches; + } + + /** + * Parsing the Accept header returns a set of these. + * + * Package access to permit unit testing. + */ + static class AcceptableType { + private final MatchCriteria matchCriteria; + private final float q; + + public AcceptableType(String name, String qString) + throws AcceptHeaderParsingException { + this.matchCriteria = new MatchCriteria(name); + this.q = parseQValue(qString); + } + + private float parseQValue(String qString) + throws AcceptHeaderParsingException { + float qValue = 0.0F; + + if (qString == null || qString.trim().isEmpty()) { + qString = "1"; + } + + try { + qValue = Float.parseFloat(qString); + } catch (Exception e) { + throw new AcceptHeaderParsingException("invalid q value: '" + + qString + "'"); + } + + if (qValue > 1.0F || qValue <= 0.0F) { + throw new AcceptHeaderParsingException("q value out of range: " + + qString); + } + + return qValue; + } + + public float fitQuality(MatchCriteria availableType) { + int matchQuality = matchCriteria.matchQuality(availableType); + if (matchQuality == 0) { + return 0; + } else { + return matchQuality + 1.0F + q; + } + } + + public String getName() { + return matchCriteria.getName(); + } + + public float getQ() { + return q; + } + } + + /** + * Parse the available type names into a list of these, so we only do the + * substring operations once. + * + * Package access to permit unit testing. + */ + static class MatchCriteria { + private final String name; + private final String type; + private final String subtype; + + MatchCriteria(String name) { + if (name == null) { + name = ""; + } + + this.name = name; + int slashHere = name.indexOf('/'); + + if (name.isEmpty()) { + this.type = "*"; + this.subtype = "*"; + } else if (slashHere == -1) { + this.type = name; + this.subtype = "*"; + } else if (slashHere == name.length() - 1) { + this.type = name.substring(0, slashHere); + this.subtype = "*"; + } else { + this.type = name.substring(0, slashHere); + this.subtype = name.substring(slashHere + 1); + } + } + + /** + * If one of the types is a wild-card, it's a weak match. + * + * Otherwise, if the types match and one of the subtypes is a wild-card, + * it's a medium match. + * + * Otherwise, if the types match and the subtypes match, it's a strong + * match. + * + * Otherwise, it is no match. + */ + public int matchQuality(MatchCriteria that) { + boolean typeMatch = this.type.equals(that.type); + boolean typeWild = this.type.equals("*") || that.type.equals("*"); + boolean subtypeMatch = this.subtype.equals(that.subtype); + boolean subtypeWild = this.subtype.equals("*") + || that.subtype.equals("*"); + + if (typeWild) { + return 1; + } else if (typeMatch && subtypeWild) { + return 2; + } else if (typeMatch && subtypeMatch) { + return 3; + } else { + return 0; + } + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } + + public String getSubtype() { + return subtype; + } + } +} diff --git a/webapp/src/edu/cornell/mannlib/vitro/webapp/utils/http/NotAcceptableException.java b/webapp/src/edu/cornell/mannlib/vitro/webapp/utils/http/NotAcceptableException.java new file mode 100644 index 000000000..b0164b398 --- /dev/null +++ b/webapp/src/edu/cornell/mannlib/vitro/webapp/utils/http/NotAcceptableException.java @@ -0,0 +1,13 @@ +/* $This file is distributed under the terms of the license in /doc/license.txt$ */ + +package edu.cornell.mannlib.vitro.webapp.utils.http; + +/** + * Indicates that none of the available types are acceptable to the client. + */ +public class NotAcceptableException extends Exception { + public NotAcceptableException(String message) { + super(message); + } + +} diff --git a/webapp/test/edu/cornell/mannlib/vitro/webapp/utils/http/ContentTypeUtilTest.java b/webapp/test/edu/cornell/mannlib/vitro/webapp/utils/http/ContentTypeUtilTest.java new file mode 100644 index 000000000..79e4a5cf7 --- /dev/null +++ b/webapp/test/edu/cornell/mannlib/vitro/webapp/utils/http/ContentTypeUtilTest.java @@ -0,0 +1,261 @@ +/* $This file is distributed under the terms of the license in /doc/license.txt$ */ + +package edu.cornell.mannlib.vitro.webapp.utils.http; + +import static org.junit.Assert.assertEquals; + +import java.util.Arrays; +import java.util.List; + +import org.junit.Test; + +import edu.cornell.mannlib.vitro.testing.AbstractTestClass; +import edu.cornell.mannlib.vitro.webapp.utils.http.ContentTypeUtil.AcceptableType; +import edu.cornell.mannlib.vitro.webapp.utils.http.ContentTypeUtil.MatchCriteria; + +/** + * TODO + */ +public class ContentTypeUtilTest extends AbstractTestClass { + + // ---------------------------------------------------------------------- + // MatchCriteria tests + // ---------------------------------------------------------------------- + + @Test + public void mcEmptyName() { + checkMatchCriteriaConstructor("MC empty name", "", "*", "*"); + } + + @Test + public void mcNullName() { + checkMatchCriteriaConstructor("MC null name", null, "*", "*"); + } + + @Test + public void mcTypeOnly() { + checkMatchCriteriaConstructor("MC type only", "image", "image", "*"); + } + + @Test + public void mcTypeAndSubtype() { + checkMatchCriteriaConstructor("MC type and subtype", "image/png", + "image", "png"); + } + + @Test + public void mcTypeAndEmptySubtype() { + checkMatchCriteriaConstructor("MC type and empty subtype", "image/", + "image", "*"); + } + + @Test + public void mcWildcardType() { + checkMatchCriteriaConstructor("MC wild card type", "*", "*", "*"); + } + + @Test + public void mcWildcardSubtype() { + checkMatchCriteriaConstructor("MC wild card subtype", "image/*", + "image", "*"); + } + + @Test + public void mcMatchWildcardType() { + checkMatchQuality("MC match wild card type 1", "*", "text", 1); + checkMatchQuality("MC match wild card type 2", "text", "*", 1); + checkMatchQuality("MC match wild card type 3", "*", "text/plain", 1); + checkMatchQuality("MC match wild card type 4", "text/*", "*", 1); + } + + @Test + public void mcTypesDontMatch() { + checkMatchQuality("MC types don't match 1", "this", "that", 0); + checkMatchQuality("MC types don't match 2", "this/match", "that/match", + 0); + } + + @Test + public void mcMatchWildcardSubtype() { + checkMatchQuality("MC match wild card subtype 1", "text", "text/xml", 2); + checkMatchQuality("MC match wild card subtype 2", "image/jpeg", + "image/*", 2); + } + + @Test + public void mcSubtypesDontMatch() { + checkMatchQuality("MC match subtypes don't match", "text/xml", + "text/plain", 0); + } + + @Test + public void mcFullMatch() { + checkMatchQuality("MC full match", "text/plain", "text/plain", 3); + } + + // ---------------------------------------------------------------------- + // AcceptableType tests + // ---------------------------------------------------------------------- + + @Test + public void atNullQ() throws AcceptHeaderParsingException { + checkAcceptableTypeConstructor("AT null Q", null, 1.0F); + } + + @Test + public void atEmptyQ() throws AcceptHeaderParsingException { + checkAcceptableTypeConstructor("AT empty Q", "", 1.0F); + } + + @Test + public void atBlankQ() throws AcceptHeaderParsingException { + checkAcceptableTypeConstructor("AT blank Q", " \t", 1.0F); + } + + @Test(expected = AcceptHeaderParsingException.class) + public void atInvalidQ() throws AcceptHeaderParsingException { + checkAcceptableTypeConstructor("AT invalid Q", "99XX", 0.0F); + } + + @Test(expected = AcceptHeaderParsingException.class) + public void atQTooHigh() throws AcceptHeaderParsingException { + checkAcceptableTypeConstructor("AT Q too high", "1.1", 0.0F); + } + + @Test(expected = AcceptHeaderParsingException.class) + public void atQTooLow() throws AcceptHeaderParsingException { + checkAcceptableTypeConstructor("AT Q too low", "0", 0.0F); + } + + @Test + public void atGoodQ() throws AcceptHeaderParsingException { + checkAcceptableTypeConstructor("AT good Q", "0.4", 0.4F); + } + + @Test + public void atWildcardMatchWorks() throws AcceptHeaderParsingException { + checkMatchQuality("AT wild card match", "*", 0.5F, "text/plain", 2.5F); + } + + @Test + public void atPartialMatchIsBetter() throws AcceptHeaderParsingException { + checkMatchQuality("AT partial match", "text", 0.5F, "text/plain", 3.5F); + } + + @Test + public void atFullMatchIsBest() throws AcceptHeaderParsingException { + checkMatchQuality("AT full match", "text/plain", 0.5F, "text/plain", + 4.5F); + } + + @Test + public void atNoMatchTotallyBites() throws AcceptHeaderParsingException { + checkMatchQuality("AT full match", "text/xml", 0.5F, "text/plain", 0.0F); + } + + // ---------------------------------------------------------------------- + // Best content type tests + // ---------------------------------------------------------------------- + @Test + public void ctNullHeaderMatchesAnything() throws Exception { + findBestMatch("CT null header matches anything", null, + available("anything"), "anything"); + } + + @Test + public void ctEmptyHeaderMatchesAnything() throws Exception { + findBestMatch("CT empty header matches anything", "", + available("anything"), "anything"); + } + + @Test + public void ctBlankHeaderMatchesAnything() throws Exception { + findBestMatch("CT blank header matches anything", " \t ", + available("anything"), "anything"); + } + + @Test(expected = NotAcceptableException.class) + public void ctNullCollectionMatchesNothing() throws Exception { + findBestMatch("CT null collection matches nothing", "*/*", null, + "nothing"); + } + + @Test(expected = NotAcceptableException.class) + public void ctEmptyCollectionMatchesNothing() throws Exception { + findBestMatch("CT empty collection matches nothing", "*/*", + available(), "nothing"); + } + + @Test + public void ctWildcardIsOK() throws Exception { + findBestMatch("CT wild card is OK", + "text/*;q=0.3, text/html;q=0.1, */*;q=0.5", + available("image/png"), "image/png"); + } + + @Test + public void ctPartialMatchIsBetter() throws Exception { + findBestMatch("CT partial match is better", + "text/*;q=0.3, text/html;q=0.1, */*;q=0.5", + available("image/png", "text/xml"), "text/xml"); + } + + @Test + public void ctFullMatchIsBest() throws Exception { + findBestMatch("CT full match is best", + "text/*;q=0.3, text/html;q=0.1, */*;q=0.5", + available("image/png", "text/xml", "text/html"), "text/html"); + } + + @Test(expected = NotAcceptableException.class) + public void ctNoMatchTotalBites() throws Exception { + findBestMatch("CT no match totally bites", + "text/*;q=0.3, text/html;q=0.1", available("no/match"), + "nothing"); + } + + // ---------------------------------------------------------------------- + // Helper methods + // ---------------------------------------------------------------------- + + private void checkMatchCriteriaConstructor(String message, String name, + String expectedType, String expectedSubtype) { + MatchCriteria mc = new MatchCriteria(name); + assertEquals(message + " - type", expectedType, mc.getType()); + assertEquals(message + " - subtype", expectedSubtype, mc.getSubtype()); + } + + private void checkMatchQuality(String message, String name1, String name2, + int expected) { + MatchCriteria mc1 = new MatchCriteria(name1); + MatchCriteria mc2 = new MatchCriteria(name2); + int actual = mc1.matchQuality(mc2); + assertEquals(message, expected, actual); + } + + private void checkAcceptableTypeConstructor(String message, String qString, + float expected) throws AcceptHeaderParsingException { + AcceptableType at = new AcceptableType("irrelevant", qString); + assertEquals(message, expected, at.getQ(), 0.0001F); + } + + private void checkMatchQuality(String message, String name1, float qValue, + String name2, float expected) throws AcceptHeaderParsingException { + AcceptableType at = new AcceptableType(name1, Float.toString(qValue)); + MatchCriteria mc = new MatchCriteria(name2); + float actual = at.fitQuality(mc); + assertEquals(message, expected, actual, 0.0001F); + } + + private List available(String... names) { + return Arrays.asList(names); + } + + private void findBestMatch(String message, String acceptHeader, + List availableTypeNames, String expected) + throws AcceptHeaderParsingException, NotAcceptableException { + String actual = ContentTypeUtil.bestContentType(acceptHeader, + availableTypeNames); + assertEquals(message, expected, actual); + } +}