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 *&#47;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}