001/* Copyright (c) 2006 Google Inc.  With trivial modifications (marked MCA) for Votorola.
002 *
003 * Licensed under the Apache License, Version 2.0 (the "License");
004 * you may not use this file except in compliance with the License.
005 * You may obtain a copy of the License at
006 *
007 *     http://www.apache.org/licenses/LICENSE-2.0
008 *
009 * Unless required by applicable law or agreed to in writing, software
010 * distributed under the License is distributed on an "AS IS" BASIS,
011 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
012 * See the License for the specific language governing permissions and
013 * limitations under the License.
014 */
015
016
017package votorola.g.web;
018
019//import com.google.gdata.client.Service;
020
021import java.util.HashMap;
022import java.util.List;
023import java.util.regex.Matcher;
024import java.util.regex.Pattern;
025
026/**
027 * Simple class for parsing and generating Content-Type header values, per
028 * RFC 2045 (MIME) and 2616 (HTTP 1.1).
029 *
030 * 
031 */
032public class ContentType {
033
034  private static String TOKEN =
035    "[\\p{ASCII}&&[^\\p{Cntrl} ;/=\\[\\]\\(\\)\\<\\>\\@\\,\\:\\\"\\?\\=]]+";
036
037  // Precisely matches a token
038  private static Pattern TOKEN_PATTERN = Pattern.compile(
039    "^" + TOKEN + "$");
040
041  // Matches a media type value
042  private static Pattern TYPE_PATTERN = Pattern.compile(
043    "(" + TOKEN + ")" +         // type  (G1)
044    "/" +                       // separator
045    "(" + TOKEN + ")" +         // subtype (G2)
046    "\\s*(.*)\\s*", Pattern.DOTALL);
047
048  // Matches an attribute value
049  private static Pattern ATTR_PATTERN = Pattern.compile(
050    "\\s*;\\s*" +
051      "(" + TOKEN + ")" +       // attr name  (G1)
052      "\\s*=\\s*" +
053      "(?:" +
054        "\"([^\"]*)\"" +        // value as quoted string (G3)
055        "|" +
056        "(" + TOKEN + ")?" +    // value as token (G2)
057      ")"
058    );
059
060  /**
061   * Name of the attribute that contains the encoding character set for
062   * the content type.
063   * @see #getCharset()
064   */
065  public static final String ATTR_CHARSET = "charset";
066
067  /**
068   * Special "*" character to match any type or subtype.
069   */
070  private static final String STAR = "*";
071
072  /**
073   * The UTF-8 charset encoding is used by default for all text and xml
074   * based MIME types.
075   */
076  private static final String DEFAULT_CHARSET = ATTR_CHARSET + "=UTF-8";
077
078  /**
079   * A ContentType constant that describes the base unqualified Atom content
080   * type.
081   */
082  public static final ContentType ATOM =
083    new ContentType("application/atom+xml;" + DEFAULT_CHARSET);
084
085  /**
086   * A ContentType constant that describes the qualified Atom entry content
087   * type.
088   *
089   * @see #getAtomEntry()
090   */
091  static final ContentType ATOM_ENTRY =
092    new ContentType("application/atom+xml;type=entry;" + DEFAULT_CHARSET) {
093      @Override
094      public boolean match(ContentType acceptedContentType) {
095        String type = acceptedContentType.getAttribute("type");
096        return super.match(acceptedContentType) &&
097            (type == null || type.equals("entry"));
098      }
099    };
100
101  /**
102   * A ContentType constant that describes the qualified Atom feed content
103   * type.
104   *
105   * @see #getAtomFeed()
106   */
107  static final ContentType ATOM_FEED =
108    new ContentType("application/atom+xml;type=feed;" + DEFAULT_CHARSET) {
109      @Override
110      public boolean match(ContentType acceptedContentType) {
111        String type = acceptedContentType.getAttribute("type");
112        return super.match(acceptedContentType) &&
113            (type == null || type.equals("feed"));
114      }
115    };
116
117  /**
118   * Returns the ContentType that should be used in contexts that expect
119   * an Atom entry.
120   *
121   * @throws UnsupportedOperationException
122   */
123  public static ContentType getAtomEntry() {
124    // Use the unqualifed type for v1, the qualifed one for later versions
125//  return Service.getVersion().isCompatible(Service.Versions.V1) ?
126//      ATOM : ATOM_ENTRY;
127//// MCA:
128    throw new UnsupportedOperationException();
129  }
130
131  /**
132   * Returns the ContentType that should be used in contexts that expect
133   * an Atom feed.
134   *
135   * @throws UnsupportedOperationException
136   */
137  public static ContentType getAtomFeed() {
138    // Use the unqualified type for v1, the qualified one for later versions
139//  return Service.getVersion().isCompatible(Service.Versions.V1) ?
140//      ATOM : ATOM_FEED;
141//// MCA:
142    throw new UnsupportedOperationException();
143  }
144
145  /**
146   * A ContentType constant that describes the Atom Service content type.
147   */
148  public static final ContentType ATOM_SERVICE =
149    new ContentType("application/atomsvc+xml;" + DEFAULT_CHARSET);
150
151  /**
152   * A ContentType constant that describes the RSS channel/item content type.
153   */
154  public static final ContentType RSS =
155    new ContentType("application/rss+xml;" + DEFAULT_CHARSET);
156
157  /**
158   * A ContentType constant that describes the JSON content type.
159   */
160  public static final ContentType JSON =
161    new ContentType("application/json;" + DEFAULT_CHARSET);
162
163  /**
164   * A ContentType constant that describes the JavaScript content type.
165   */
166  public static final ContentType JAVASCRIPT =
167    new ContentType("text/javascript;" + DEFAULT_CHARSET);
168
169  /**
170   * A ContentType constant that describes the generic text/xml content type.
171   */
172  public static final ContentType TEXT_XML =
173    new ContentType("text/xml;" + DEFAULT_CHARSET);
174
175  /**
176   * A ContentType constant that describes the generic text/html content type.
177   */
178  public static final ContentType TEXT_HTML =
179    new ContentType("text/html;" + DEFAULT_CHARSET);
180
181  /**
182   * A ContentType constant that describes the generic text/plain content type.
183   */
184  public static final ContentType TEXT_PLAIN =
185    new ContentType("text/plain;" + DEFAULT_CHARSET);
186
187  /**
188   * A ContentType constant that describes the MIME multipart/related content
189   * type.
190   */
191  public static final ContentType MULTIPART_RELATED =
192    new ContentType("multipart/related");
193
194  /**
195   * Determines the best "Content-Type" header to use in a servlet response
196   * based on the "Accept" header from a servlet request.
197   *
198   * @param acceptHeader       "Accept" header value from a servlet request (not
199   *                           <code>null</code>)
200   * @param actualContentTypes actual content types in descending order of
201   *                           preference (non-empty, and each entry is of the
202   *                           form "type/subtype" without the wildcard char
203   *                           '*') or <code>null</code> if no "Accept" header
204   *                           was specified
205   * @return the best content type to use (or <code>null</code> on no match).
206   */
207  public static ContentType getBestContentType(String acceptHeader,
208      List<ContentType> actualContentTypes) {
209
210    // If not accept header is specified, return the first actual type
211    if (acceptHeader == null) {
212      return actualContentTypes.get(0);
213    }
214
215    // iterate over all of the accepted content types to find the best match
216    float bestQ = 0;
217    ContentType bestContentType = null;
218    String[] acceptedTypes = acceptHeader.split(",");
219    for (String acceptedTypeString : acceptedTypes) {
220
221      // create the content type object
222      ContentType acceptedContentType;
223      try {
224        acceptedContentType = new ContentType(acceptedTypeString.trim());
225      } catch (IllegalArgumentException ex) {
226        // ignore exception
227        continue;
228      }
229
230      // parse the "q" value (default of 1)
231      float curQ = 1;
232      try {
233        String qAttr = acceptedContentType.getAttribute("q");
234        if (qAttr != null) {
235          float qValue = Float.valueOf(qAttr);
236          if (qValue <= 0 || qValue > 1) {
237            continue;
238          }
239          curQ = qValue;
240        }
241      } catch (NumberFormatException ex) {
242        // ignore exception
243        continue;
244      }
245
246      // only check it if it's at least as good ("q") as the best one so far
247      if (curQ < bestQ) {
248        continue;
249      }
250
251      /* iterate over the actual content types in order to find the best match
252      to the current accepted content type */
253      for (ContentType actualContentType : actualContentTypes) {
254
255        /* if the "q" value is the same as the current best, only check for
256        better content types */
257        if (curQ == bestQ && bestContentType == actualContentType) {
258          break;
259        }
260
261        /* check if the accepted content type matches the current actual
262        content type */
263        if (actualContentType.match(acceptedContentType)) {
264          bestContentType = actualContentType;
265          bestQ = curQ;
266          break;
267        }
268      }
269    }
270
271    // if found an acceptable content type, return the best one
272    if (bestQ != 0) {
273      return bestContentType;
274    }
275
276    // Return null if no match
277    return null;
278  }
279
280 /**
281   * Constructs a new instance with default media type
282   */
283  public ContentType() {
284    this(null);
285  }
286
287  /**
288   * Constructs a new instance from a content-type header value
289   * parsing the MIME content type (RFC2045) format.  If the type
290   * is {@code null}, then media type and charset will be
291   * initialized to default values.
292   *
293   * @param typeHeader content type value in RFC2045 header format.
294   */
295  public ContentType(String typeHeader) {
296
297    // If the type header is no provided, then use the HTTP defaults.
298    if (typeHeader == null) {
299      type = "application";
300      subType = "octet-stream";
301      attributes.put(ATTR_CHARSET, "iso-8859-1"); // http default
302      return;
303    }
304
305    // Get type and subtype
306    Matcher typeMatch = TYPE_PATTERN.matcher(typeHeader);
307    if (!typeMatch.matches()) {
308      throw new IllegalArgumentException("Invalid media type:" + typeHeader);
309    }
310
311    type = typeMatch.group(1).toLowerCase();
312    subType = typeMatch.group(2).toLowerCase();
313    if (typeMatch.groupCount() < 3) {
314      return;
315    }
316
317    // Get attributes (if any)
318    Matcher attrMatch = ATTR_PATTERN.matcher(typeMatch.group(3));
319    while (attrMatch.find()) {
320
321      String value = attrMatch.group(2);
322      if (value == null) {
323        value = attrMatch.group(3);
324        if (value == null) {
325          value = "";
326        }
327      }
328
329      attributes.put(attrMatch.group(1).toLowerCase(), value);
330    }
331
332    // Infer a default charset encoding if unspecified.
333    if (!attributes.containsKey(ATTR_CHARSET)) {
334      inferredCharset = true;
335      if (subType.endsWith("xml")) {
336        if (type.equals("application")) {
337          // BUGBUG: Actually have need to look at the raw stream here, but
338          // if client omitted the charset for "application/xml", they are
339          // ignoring the STRONGLY RECOMMEND language in RFC 3023, sec 3.2.
340          // I have little sympathy.
341          attributes.put(ATTR_CHARSET, "utf-8");    // best guess
342        } else {
343          attributes.put(ATTR_CHARSET, "us-ascii"); // RFC3023, sec 3.1
344        }
345      } else if (subType.equals("json")) {
346        attributes.put(ATTR_CHARSET, "utf-8");    // RFC4627, sec 3
347      } else {
348        attributes.put(ATTR_CHARSET, "iso-8859-1"); // http default
349      }
350    }
351  }
352
353  /** {code True} if parsed input didn't contain charset encoding info */
354  private boolean inferredCharset = false;
355
356  private String type;
357  public String getType() { return type; }
358  public void setType(String type) { this.type = type; }
359
360
361  private String subType;
362  public String getSubType() { return subType; }
363  public void setSubType(String subType) { this.subType = subType; }
364
365  /** Returns the full media type */
366  public String getMediaType() {
367    StringBuilder sb = new StringBuilder(); // MCA instead of StringBuffer
368    sb.append(type);
369    sb.append("/");
370    sb.append(subType);
371    return sb.toString();
372  }
373
374  private HashMap<String, String> attributes = new HashMap<String, String>();
375
376  /**
377   * Returns the additional attributes of the content type.
378   */
379  public HashMap<String, String> getAttributes() { return attributes; }
380
381
382  /**
383   * Returns the additional attribute by name of the content type.
384   *
385   * @param name attribute name
386   */
387  public String getAttribute(String name) {
388    return attributes.get(name);
389  }
390
391  /*
392   * Returns the charset attribute of the content type or null if the
393   * attribute has not been set.
394   */
395  public String getCharset() { return attributes.get(ATTR_CHARSET); }
396
397
398  /**
399   * Returns whether this content type is match by the content type found in the
400   * "Accept" header field of an HTTP request.
401   *
402   * @param acceptedContentType content type found in the "Accept" header field
403   *                            of an HTTP request
404   */
405  public boolean match(ContentType acceptedContentType) {
406    String acceptedType = acceptedContentType.getType();
407    String acceptedSubType = acceptedContentType.getSubType();
408    return STAR.equals(acceptedType) || type.equals(acceptedType) &&
409        (STAR.equals(acceptedSubType) || subType.equals(acceptedSubType));
410  }
411
412
413  /**
414   * Generates the Content-Type value
415   */
416  @Override
417  public String toString() {
418
419    StringBuilder sb = new StringBuilder(); // MCA instead of StringBuffer
420    sb.append(type);
421    sb.append("/");
422    sb.append(subType);
423    for (String name : attributes.keySet()) {
424
425      // Don't include any inferred charset attribute in output.
426      if (inferredCharset && ATTR_CHARSET.equals(name)) {
427        continue;
428      }
429      sb.append(";");
430      sb.append(name);
431      sb.append("=");
432      String value = attributes.get(name);
433      Matcher tokenMatcher = TOKEN_PATTERN.matcher(value);
434      if (tokenMatcher.matches()) {
435        sb.append(value);
436      } else {
437        sb.append("\"" + value + "\"");
438      }
439    }
440    return sb.toString();
441  }
442
443
444  @Override
445  public boolean equals(Object o) {
446    if (this == o) {
447      return true;
448    }
449    if (o == null || getClass() != o.getClass()) {
450      return false;
451    }
452    ContentType that = (ContentType) o;
453    return type.equals(that.type) && subType.equals(that.subType) && attributes
454        .equals(that.attributes);
455  }
456
457
458  @Override
459  public int hashCode() {
460    return (type.hashCode() * 31 + subType.hashCode()) * 31 + attributes
461        .hashCode();
462  }
463}