001/*
002Copyright (c) 2010 Joe Gregorio
003
004Permission is hereby granted, free of charge, to any person obtaining a copy
005of this software and associated documentation files (the "Software"), to deal
006in the Software without restriction, including without limitation the rights
007to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
008copies of the Software, and to permit persons to whom the Software is
009furnished to do so, subject to the following conditions:
010
011The above copyright notice and this permission notice shall be included in
012all copies or substantial portions of the Software.
013
014THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
015IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
016FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
017AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
018LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
019OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
020THE SOFTWARE.
021 */
022package org.kohsuke.stapler;
023
024import org.apache.commons.beanutils.Converter;
025import org.apache.commons.lang.StringUtils;
026import org.apache.commons.lang.math.NumberUtils;
027
028import javax.annotation.Nullable;
029import javax.servlet.http.HttpServletResponse;
030import java.util.ArrayList;
031import java.util.Arrays;
032import java.util.HashMap;
033import java.util.List;
034import java.util.Map;
035
036/**
037 * Represents the <tt>Accept</tt> HTTP header and help server choose the right media type to serve.
038 *
039 * <p>
040 * Typical usage:
041 * </p>
042 * <pre>
043 * HttpResponse doXyz(&#64;Header("Accept") AcceptHeader accept, ...) {
044 *     switch (accept.select("application/json","text/xml")) {
045 *     case "application/json":
046 *         ...
047 *     case "text/html":
048 *         ...
049 *     }
050 * }
051 * </pre>
052 *
053 * <p>
054 * A port to Java of Joe Gregorio's MIME-Type Parser: http://code.google.com/p/mimeparse/
055 * Ported by Tom Zellman &lt;tzellman@gmail.com&gt;.
056 *
057 * @see <a href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1">definition of Accept header</a>
058 */
059public final class AcceptHeader {
060    private final List<Atom> atoms = new ArrayList<Atom>();
061    private final String ranges;
062
063    /**
064     * Parse the accept header value into a typed object.
065     *
066     * @param ranges
067     *      something like "text/*;q=0.5,*; q=0.1"
068     */
069    public AcceptHeader(String ranges) {
070        this.ranges = ranges;
071        for (String r : StringUtils.split(ranges, ','))
072            atoms.add(new Atom(r));
073    }
074
075    /**
076     * Media range plus parameters and extensions
077     */
078    protected static class Atom {
079        private final String major;
080        private final String minor;
081        private final Map<String, String> params = new HashMap<String, String>();
082
083        private final float q;
084
085        @Override
086        public String toString() {
087            StringBuilder s = new StringBuilder(major +'/'+ minor);
088            for (String k : params.keySet())
089                s.append(";").append(k).append("=").append(params.get(k));
090            return s.toString();
091        }
092
093        /**
094         * Parses a string like 'application/*;q=0.5' into a typed object.
095         */
096        protected Atom(String range) {
097            String[] parts = StringUtils.split(range, ";");
098
099            for (int i = 1; i < parts.length; ++i) {
100                String p = parts[i];
101                String[] subParts = StringUtils.split(p, '=');
102                if (subParts.length == 2)
103                    params.put(subParts[0].trim(), subParts[1].trim());
104            }
105            String fullType = parts[0].trim();
106
107            // Java URLConnection class sends an Accept header that includes a
108            // single "*" - Turn it into a legal wildcard.
109            if (fullType.equals("*"))
110                fullType = "*/*";
111            String[] types = StringUtils.split(fullType, "/");
112            major = types[0].trim();
113            minor = types[1].trim();
114
115            float q = NumberUtils.toFloat(params.get("q"), 1);
116            if (q < 0 || q > 1)
117                q = 1;
118            this.q = q;
119
120            params.remove("q"); // normalize this away as this gets in the fitting
121        }
122
123        /**
124         * Consider the score of fitness between two Atoms.
125         *
126         * <p>
127         * Higher fitness means better match. For example, "text/html;level=1" fits "text/html" better
128         * than "text/*", which still fits better than "* /*"
129         */
130        private int fit(Atom that) {
131            if (!wildcardMatch(that.major, this.major) || !wildcardMatch(that.minor, this.minor))
132                return -1;
133
134            int fitness;
135            fitness = (this.major.equals(that.major)) ? 10000 : 0;
136            fitness += (this.minor.equals(that.minor)) ? 1000 : 0;
137
138            // parameter matches increase score
139            for (String k : that.params.keySet()) {
140                if (that.params.get(k).equals(this.params.get(k))) {
141                    fitness++;
142                }
143            }
144
145            return fitness;
146        }
147    }
148
149    private static boolean wildcardMatch(String a, String b) {
150        return a.equals(b) || a.equals("*") || b.equals("*");
151    }
152
153    /**
154     * Given a MIME type, find the entry from this Accept header that fits the best.
155     *
156     * @param mimeType
157     */
158    protected @Nullable Atom match(String mimeType) {
159        Atom target = new Atom(mimeType);
160
161        int bestFitness = -1;
162        Atom best = null;
163        for (Atom a : atoms) {
164            int f = a.fit(target);
165            if (f>bestFitness) {
166                best = a;
167                bestFitness = f;
168            }
169        }
170
171        return best;
172    }
173
174    /**
175     * Takes a list of supported mime-types and finds the best match for all the
176     * media-ranges listed in header. The value of header must be a string that
177     * conforms to the format of the HTTP Accept: header. The value of
178     * 'supported' is a list of mime-types.
179     *
180     * <pre>{@code
181     * // Client: I prefer text/*, but if not I'm happy to take anything
182     * // Server: I can serve you xbel or xml
183     * // Result: let's serve you text/xml
184     * new AcceptHeader("text/*;q=0.5, *;q=0.1").select("application/xbel+xml", "text/xml") => "text/xml"
185     *
186     * // Client: I want image, ideally PNG
187     * // Server: I can give you plain text or XML
188     * // Result: there's nothing to serve you here
189     * new AcceptHeader("image/*;q=0.5, image/png;q=1").select("text/plain","text/xml") => null
190     * }</pre>
191     *
192     * @return null if none of the choices in {@code supported} is acceptable to the client.
193     */
194    public String select(Iterable<String> supported) {
195        float bestQ = 0;
196        String best = null;
197
198        for (String s : supported) {
199            Atom a = match(s);
200            if (a!= null && a.q > bestQ) {
201                bestQ = a.q;
202                best = s;
203            }
204        }
205
206        if (best==null)
207            throw HttpResponses.error(HttpServletResponse.SC_NOT_ACCEPTABLE,
208                    "Requested MIME types '" + ranges + "' didn't match any of the available options "+supported);
209        return best;
210    }
211
212    public String select(String... supported) {
213        return select(Arrays.asList(supported));
214    }
215
216    @Override
217    public String toString() {
218        return super.toString()+"["+ranges+"]";
219    }
220
221    // this performs databinding for @Header parameter injection
222    public static class StaplerConverterImpl implements Converter {
223        public Object convert(Class type, Object value) {
224            return new AcceptHeader(value.toString());
225        }
226    }
227}