001/* 002 * Copyright 2022-2026 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.soklet; 018 019import org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.ThreadSafe; 023import java.math.BigDecimal; 024import java.util.Collections; 025import java.util.LinkedHashMap; 026import java.util.List; 027import java.util.Locale; 028import java.util.Map; 029import java.util.Objects; 030import java.util.Optional; 031 032import static java.util.Objects.requireNonNull; 033 034/** 035 * An immutable representation of a single media range from an HTTP {@code Accept} header value, e.g. 036 * {@code text/html;level=1;q=0.7}, as defined by 037 * <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1">RFC 9110, Section 12.5.1</a>. 038 * <p> 039 * The {@linkplain #getType() type} and {@linkplain #getSubtype() subtype} are normalized to lowercase and either 040 * may be the {@code *} wildcard (a wildcard type requires a wildcard subtype). The {@linkplain #getQuality() quality} 041 * is the {@code q} weight parameter, defaulting to {@code 1} when absent and clamped to the range 042 * {@code [0, 1]}; per RFC 9110 it is recognized regardless of its position among the parameters. 043 * {@linkplain #getParameters() Parameters} are all non-{@code q} media-type parameters, whether they 044 * appear before or after {@code q} (RFC 9110 removed RFC 7231's {@code accept-ext} grammar). 045 * <p> 046 * See {@link Request#getMediaRanges()} for the ordered media ranges of a request's {@code Accept} header value[s]. 047 * 048 * @author <a href="https://www.revetkn.com">Mark Allen</a> 049 */ 050@ThreadSafe 051public final class MediaRange { 052 @NonNull 053 private static final BigDecimal DEFAULT_QUALITY = BigDecimal.ONE; 054 055 @NonNull 056 private final String type; 057 @NonNull 058 private final String subtype; 059 @NonNull 060 private final BigDecimal quality; 061 @NonNull 062 private final Map<@NonNull String, @NonNull String> parameters; 063 064 private MediaRange(@NonNull String type, 065 @NonNull String subtype, 066 @NonNull BigDecimal quality, 067 @NonNull Map<@NonNull String, @NonNull String> parameters) { 068 requireNonNull(type); 069 requireNonNull(subtype); 070 requireNonNull(quality); 071 requireNonNull(parameters); 072 073 this.type = type; 074 this.subtype = subtype; 075 // Normalize scale (e.g. 0.50 -> 0.5) so equals/hashCode can use plain BigDecimal equality 076 this.quality = quality.stripTrailingZeros(); 077 // Preserve parameter order (Map.copyOf does not) 078 this.parameters = Collections.unmodifiableMap(new LinkedHashMap<>(parameters)); 079 } 080 081 /** 082 * Parses a single media range, e.g. {@code text/html;level=1;q=0.7}, per 083 * <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1">RFC 9110, Section 12.5.1</a>. 084 * <p> 085 * Parsing is lenient: a malformed representation yields {@link Optional#empty()} rather than an exception. 086 * A representation is considered malformed if it lacks a {@code type/subtype} structure, uses invalid 087 * HTTP tokens for the type, subtype, or parameter names, pairs a wildcard type with a concrete subtype 088 * (e.g. {@code */html}), or carries an unparseable {@code q} value. 089 * Quality values outside {@code [0, 1]} are clamped. The {@code q} weight parameter is recognized at any 090 * position; if multiple {@code q} parameters are present, all but the first are ignored. All other 091 * parameters are retained as media-type parameters. Quoted parameter values have their surrounding 092 * quotes removed and quoted-pair escape sequences (e.g. {@code \"}) unescaped. 093 * 094 * @param mediaRange the media range header representation to parse, e.g. {@code text/html;q=0.7} 095 * @return the parsed media range, or {@link Optional#empty()} if the input is missing or malformed 096 */ 097 @NonNull 098 public static Optional<MediaRange> fromHeaderRepresentation(@Nullable String mediaRange) { 099 if (mediaRange == null) 100 return Optional.empty(); 101 102 String normalized = mediaRange.trim(); 103 104 if (normalized.isEmpty()) 105 return Optional.empty(); 106 107 List<String> segments = Utilities.splitSemicolonAware(normalized); 108 109 if (segments.isEmpty()) 110 return Optional.empty(); 111 112 String[] typeAndSubtype = segments.get(0).trim().toLowerCase(Locale.ROOT).split("/", 2); 113 114 if (typeAndSubtype.length != 2) 115 return Optional.empty(); 116 117 String type = typeAndSubtype[0].trim(); 118 String subtype = typeAndSubtype[1].trim(); 119 120 if (!isToken(type) || !isToken(subtype)) 121 return Optional.empty(); 122 123 // A wildcard type with a concrete subtype (e.g. "*/html") is not a valid media range 124 if ("*".equals(type) && !"*".equals(subtype)) 125 return Optional.empty(); 126 127 BigDecimal quality = DEFAULT_QUALITY; 128 Map<String, String> parameters = new LinkedHashMap<>(); 129 boolean qualityEncountered = false; 130 131 for (int i = 1; i < segments.size(); i++) { 132 String segment = segments.get(i).trim(); 133 int equalsIndex = segment.indexOf('='); 134 135 if (equalsIndex == -1) 136 continue; 137 138 String name = segment.substring(0, equalsIndex).trim().toLowerCase(Locale.ROOT); 139 String value = unquote(segment.substring(equalsIndex + 1).trim()); 140 141 if (!isToken(name)) 142 return Optional.empty(); 143 144 if ("q".equals(name)) { 145 // RFC 9110 directs recipients to process a parameter named "q" as the weight regardless 146 // of its position; when multiple "q" parameters are present, the first wins 147 if (!qualityEncountered) { 148 try { 149 quality = clampQuality(new BigDecimal(value)); 150 } catch (NumberFormatException e) { 151 return Optional.empty(); 152 } 153 154 qualityEncountered = true; 155 } 156 } else { 157 // RFC 9110 removed RFC 7231's accept-ext grammar: every non-"q" parameter is a 158 // media-type parameter, regardless of whether it appears before or after "q" 159 parameters.put(name, value); 160 } 161 } 162 163 return Optional.of(new MediaRange(type, subtype, quality, parameters)); 164 } 165 166 private static boolean isToken(@NonNull String string) { 167 requireNonNull(string); 168 169 if (string.isEmpty()) 170 return false; 171 172 for (int i = 0; i < string.length(); i++) { 173 char ch = string.charAt(i); 174 175 if (!isTchar(ch)) 176 return false; 177 } 178 179 return true; 180 } 181 182 private static boolean isTchar(char ch) { 183 if (ch >= '0' && ch <= '9') 184 return true; 185 186 if (ch >= 'A' && ch <= 'Z') 187 return true; 188 189 if (ch >= 'a' && ch <= 'z') 190 return true; 191 192 switch (ch) { 193 case '!': 194 case '#': 195 case '$': 196 case '%': 197 case '&': 198 case '\'': 199 case '*': 200 case '+': 201 case '-': 202 case '.': 203 case '^': 204 case '_': 205 case '`': 206 case '|': 207 case '~': 208 return true; 209 default: 210 return false; 211 } 212 } 213 214 @NonNull 215 private static String unquote(@NonNull String value) { 216 requireNonNull(value); 217 218 if (value.length() < 2 || value.charAt(0) != '"' || value.charAt(value.length() - 1) != '"') 219 return value; 220 221 String inner = value.substring(1, value.length() - 1); 222 223 // Unescape RFC 9110 quoted-pair sequences (e.g. \" and \\); liberal in what we accept 224 StringBuilder unescaped = new StringBuilder(inner.length()); 225 boolean escaped = false; 226 227 for (int i = 0; i < inner.length(); i++) { 228 char c = inner.charAt(i); 229 230 if (escaped) { 231 unescaped.append(c); 232 escaped = false; 233 } else if (c == '\\') { 234 escaped = true; 235 } else { 236 unescaped.append(c); 237 } 238 } 239 240 return unescaped.toString(); 241 } 242 243 @NonNull 244 private static BigDecimal clampQuality(@NonNull BigDecimal quality) { 245 requireNonNull(quality); 246 247 if (quality.compareTo(BigDecimal.ZERO) < 0) 248 return BigDecimal.ZERO; 249 250 if (quality.compareTo(BigDecimal.ONE) > 0) 251 return BigDecimal.ONE; 252 253 return quality; 254 } 255 256 @Override 257 public boolean equals(@Nullable Object object) { 258 if (this == object) 259 return true; 260 261 if (!(object instanceof MediaRange mediaRange)) 262 return false; 263 264 // Quality is scale-normalized in the constructor, so plain equality is scale-insensitive 265 return Objects.equals(getType(), mediaRange.getType()) 266 && Objects.equals(getSubtype(), mediaRange.getSubtype()) 267 && Objects.equals(getQuality(), mediaRange.getQuality()) 268 && Objects.equals(getParameters(), mediaRange.getParameters()); 269 } 270 271 @Override 272 public int hashCode() { 273 return Objects.hash(getType(), getSubtype(), getQuality(), getParameters()); 274 } 275 276 @Override 277 @NonNull 278 public String toString() { 279 return "%s{type=%s, subtype=%s, quality=%s, parameters=%s}".formatted( 280 getClass().getSimpleName(), getType(), getSubtype(), getQuality(), getParameters()); 281 } 282 283 /** 284 * The type portion of this media range (e.g. {@code text} in {@code text/html}), lowercase; may be the 285 * {@code *} wildcard. 286 * 287 * @return the media range's type 288 */ 289 @NonNull 290 public String getType() { 291 return this.type; 292 } 293 294 /** 295 * The subtype portion of this media range (e.g. {@code html} in {@code text/html}), lowercase; may be the 296 * {@code *} wildcard. 297 * 298 * @return the media range's subtype 299 */ 300 @NonNull 301 public String getSubtype() { 302 return this.subtype; 303 } 304 305 /** 306 * The {@code q} weight of this media range in the range {@code [0, 1]}, defaulting to {@code 1} when 307 * unspecified. A quality of {@code 0} means "not acceptable". 308 * 309 * @return the media range's quality weight 310 */ 311 @NonNull 312 public BigDecimal getQuality() { 313 return this.quality; 314 } 315 316 /** 317 * The media-type parameters of this media range (e.g. {@code level=1} in {@code text/html;level=1;q=0.7}), 318 * with lowercase names in their original order. Excludes only the {@code q} weight parameter; all other 319 * parameters are included regardless of their position relative to {@code q}. 320 * 321 * @return an unmodifiable view of the media range's parameters, or an empty map if none were specified 322 */ 323 @NonNull 324 public Map<@NonNull String, @NonNull String> getParameters() { 325 return this.parameters; 326 } 327 328 /** 329 * Is this media range's type the {@code *} wildcard (i.e. {@code *}{@code /*})? 330 * 331 * @return {@code true} if the type is a wildcard, {@code false} otherwise 332 */ 333 @NonNull 334 public Boolean isWildcardType() { 335 return "*".equals(getType()); 336 } 337 338 /** 339 * Is this media range's subtype the {@code *} wildcard (e.g. {@code text/*})? 340 * 341 * @return {@code true} if the subtype is a wildcard, {@code false} otherwise 342 */ 343 @NonNull 344 public Boolean isWildcardSubtype() { 345 return "*".equals(getSubtype()); 346 } 347}