001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.commons.http;
018
019import static org.apache.juneau.commons.utils.StringUtils.*;
020
021import java.util.*;
022
023/**
024 * Lightweight parser for HTTP header values as defined by RFC 2616.
025 *
026 * <p>
027 * Parses comma-delimited header values into {@link HeaderElement} objects, where each element
028 * has a name and optional semicolon-delimited parameters.
029 *
030 * <p>
031 * For example, the header value <js>"text/html;charset=UTF-8;q=0.9, text/json"</js> is parsed into two
032 * elements: <js>"text/html"</js> with parameters <js>"charset=UTF-8"</js> and <js>"q=0.9"</js>,
033 * and <js>"text/json"</js> with no parameters.
034 */
035public final class HeaderValueParser {
036
037   /**
038    * Parses an HTTP header value into an array of {@link HeaderElement} objects.
039    *
040    * <p>
041    * Elements are separated by commas. Each element may have parameters separated by semicolons.
042    * Quoted strings are respected (commas and semicolons inside quotes are not treated as delimiters).
043    *
044    * @param value The header value to parse. May be <jk>null</jk> or empty.
045    * @return An array of parsed elements, never <jk>null</jk>.
046    */
047   @SuppressWarnings({
048      "java:S3776" // Cognitive complexity acceptable for parser logic
049   })
050   public static HeaderElement[] readElements(String value) {
051      if (isEmpty(value))
052         return new HeaderElement[0];
053
054      var elements = new ArrayList<HeaderElement>();
055      var len = value.length();
056      var pos = 0;
057
058      while (pos < len) {
059         pos = skipWhitespace(value, pos);
060         if (pos >= len)
061            break;
062
063         var name = readName(value, pos);
064         pos += name.length();
065         name = name.trim();
066
067         var params = new ArrayList<NameValuePair>();
068
069         while (pos < len && value.charAt(pos) == ';') {
070            pos++;
071            pos = skipWhitespace(value, pos);
072
073            var paramName = readToken(value, pos, '=', ';', ',');
074            pos += paramName.length();
075            paramName = paramName.trim();
076
077            String paramValue = null;
078            if (pos < len && value.charAt(pos) == '=') {
079               pos++;
080               pos = skipWhitespace(value, pos);
081               if (pos < len && value.charAt(pos) == '"') {
082                  var q = readQuotedString(value, pos);
083                  paramValue = q.text();
084                  pos = q.nextPos();
085               } else {
086                  paramValue = readToken(value, pos, ';', ',');
087                  pos += paramValue.length();
088                  paramValue = paramValue.trim();
089               }
090            }
091
092            if (!paramName.isEmpty())
093               params.add(new BasicNameValuePair(paramName, paramValue));
094         }
095
096         if (!name.isEmpty())
097            elements.add(new HeaderElement(name, params.toArray(NameValuePair.EMPTY_ARRAY)));
098
099         if (pos < len && value.charAt(pos) == ',')
100            pos++;
101      }
102
103      return elements.toArray(new HeaderElement[0]);
104   }
105
106   private static String readName(String value, int pos) {
107      return readToken(value, pos, ';', ',');
108   }
109
110   private static String readToken(String value, int pos, char... delimiters) {
111      var start = pos;
112      var len = value.length();
113      var inQuotes = false;
114
115      while (pos < len) {
116         var c = value.charAt(pos);
117         if (c == '"')
118            inQuotes = !inQuotes;
119         if (!inQuotes) {
120            for (var d : delimiters)
121               if (c == d)
122                  return value.substring(start, pos);
123         }
124         pos++;
125      }
126      return value.substring(start, pos);
127   }
128
129   /**
130    * Parses a double-quoted header parameter value, honoring {@code \} escapes.
131    *
132    * @param value Full header string.
133    * @param openQuoteIndex Index of the opening {@code "} character.
134    * @return Parsed text and the index immediately after the closing {@code "}, or after the last character if
135    *    the string is unterminated.
136    */
137   private static QuotedString readQuotedString(String value, int openQuoteIndex) {
138      var sb = new StringBuilder();
139      var pos = openQuoteIndex + 1;
140      var len = value.length();
141      while (pos < len) {
142         var c = value.charAt(pos);
143         if (c == '\\' && pos + 1 < len) {
144            sb.append(value.charAt(pos + 1));
145            pos += 2;
146         } else if (c == '"') {
147            return new QuotedString(sb.toString(), pos + 1);
148         } else {
149            sb.append(c);
150            pos++;
151         }
152      }
153      return new QuotedString(sb.toString(), pos);
154   }
155
156   private record QuotedString(String text, int nextPos) {}
157
158   private static int skipWhitespace(String value, int pos) {
159      while (pos < value.length() && (value.charAt(pos) == ' ' || value.charAt(pos) == '\t'))
160         pos++;
161      return pos;
162   }
163}