001/*
002 * Copyright 2002-2020 the original author or authors.
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 *      https://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 org.springframework.util;
018
019import java.io.IOException;
020import java.io.ObjectInputStream;
021import java.io.Serializable;
022import java.nio.charset.Charset;
023import java.util.BitSet;
024import java.util.Collection;
025import java.util.Collections;
026import java.util.Comparator;
027import java.util.Iterator;
028import java.util.LinkedHashMap;
029import java.util.List;
030import java.util.Locale;
031import java.util.Map;
032import java.util.TreeSet;
033
034import org.springframework.lang.Nullable;
035
036/**
037 * Represents a MIME Type, as originally defined in RFC 2046 and subsequently
038 * used in other Internet protocols including HTTP.
039 *
040 * <p>This class, however, does not contain support for the q-parameters used
041 * in HTTP content negotiation. Those can be found in the subclass
042 * {@code org.springframework.http.MediaType} in the {@code spring-web} module.
043 *
044 * <p>Consists of a {@linkplain #getType() type} and a {@linkplain #getSubtype() subtype}.
045 * Also has functionality to parse MIME Type values from a {@code String} using
046 * {@link #valueOf(String)}. For more parsing options see {@link MimeTypeUtils}.
047 *
048 * @author Arjen Poutsma
049 * @author Juergen Hoeller
050 * @author Rossen Stoyanchev
051 * @author Sam Brannen
052 * @since 4.0
053 * @see MimeTypeUtils
054 */
055public class MimeType implements Comparable<MimeType>, Serializable {
056
057        private static final long serialVersionUID = 4085923477777865903L;
058
059
060        protected static final String WILDCARD_TYPE = "*";
061
062        private static final String PARAM_CHARSET = "charset";
063
064        private static final BitSet TOKEN;
065
066        static {
067                // variable names refer to RFC 2616, section 2.2
068                BitSet ctl = new BitSet(128);
069                for (int i = 0; i <= 31; i++) {
070                        ctl.set(i);
071                }
072                ctl.set(127);
073
074                BitSet separators = new BitSet(128);
075                separators.set('(');
076                separators.set(')');
077                separators.set('<');
078                separators.set('>');
079                separators.set('@');
080                separators.set(',');
081                separators.set(';');
082                separators.set(':');
083                separators.set('\\');
084                separators.set('\"');
085                separators.set('/');
086                separators.set('[');
087                separators.set(']');
088                separators.set('?');
089                separators.set('=');
090                separators.set('{');
091                separators.set('}');
092                separators.set(' ');
093                separators.set('\t');
094
095                TOKEN = new BitSet(128);
096                TOKEN.set(0, 128);
097                TOKEN.andNot(ctl);
098                TOKEN.andNot(separators);
099        }
100
101
102        private final String type;
103
104        private final String subtype;
105
106        private final Map<String, String> parameters;
107
108        @Nullable
109        private transient Charset resolvedCharset;
110
111        @Nullable
112        private volatile String toStringValue;
113
114
115        /**
116         * Create a new {@code MimeType} for the given primary type.
117         * <p>The {@linkplain #getSubtype() subtype} is set to <code>"&#42;"</code>,
118         * and the parameters are empty.
119         * @param type the primary type
120         * @throws IllegalArgumentException if any of the parameters contains illegal characters
121         */
122        public MimeType(String type) {
123                this(type, WILDCARD_TYPE);
124        }
125
126        /**
127         * Create a new {@code MimeType} for the given primary type and subtype.
128         * <p>The parameters are empty.
129         * @param type the primary type
130         * @param subtype the subtype
131         * @throws IllegalArgumentException if any of the parameters contains illegal characters
132         */
133        public MimeType(String type, String subtype) {
134                this(type, subtype, Collections.emptyMap());
135        }
136
137        /**
138         * Create a new {@code MimeType} for the given type, subtype, and character set.
139         * @param type the primary type
140         * @param subtype the subtype
141         * @param charset the character set
142         * @throws IllegalArgumentException if any of the parameters contains illegal characters
143         */
144        public MimeType(String type, String subtype, Charset charset) {
145                this(type, subtype, Collections.singletonMap(PARAM_CHARSET, charset.name()));
146                this.resolvedCharset = charset;
147        }
148
149        /**
150         * Copy-constructor that copies the type, subtype, parameters of the given {@code MimeType},
151         * and allows to set the specified character set.
152         * @param other the other MimeType
153         * @param charset the character set
154         * @throws IllegalArgumentException if any of the parameters contains illegal characters
155         * @since 4.3
156         */
157        public MimeType(MimeType other, Charset charset) {
158                this(other.getType(), other.getSubtype(), addCharsetParameter(charset, other.getParameters()));
159                this.resolvedCharset = charset;
160        }
161
162        /**
163         * Copy-constructor that copies the type and subtype of the given {@code MimeType},
164         * and allows for different parameter.
165         * @param other the other MimeType
166         * @param parameters the parameters (may be {@code null})
167         * @throws IllegalArgumentException if any of the parameters contains illegal characters
168         */
169        public MimeType(MimeType other, @Nullable Map<String, String> parameters) {
170                this(other.getType(), other.getSubtype(), parameters);
171        }
172
173        /**
174         * Create a new {@code MimeType} for the given type, subtype, and parameters.
175         * @param type the primary type
176         * @param subtype the subtype
177         * @param parameters the parameters (may be {@code null})
178         * @throws IllegalArgumentException if any of the parameters contains illegal characters
179         */
180        public MimeType(String type, String subtype, @Nullable Map<String, String> parameters) {
181                Assert.hasLength(type, "'type' must not be empty");
182                Assert.hasLength(subtype, "'subtype' must not be empty");
183                checkToken(type);
184                checkToken(subtype);
185                this.type = type.toLowerCase(Locale.ENGLISH);
186                this.subtype = subtype.toLowerCase(Locale.ENGLISH);
187                if (!CollectionUtils.isEmpty(parameters)) {
188                        Map<String, String> map = new LinkedCaseInsensitiveMap<>(parameters.size(), Locale.ENGLISH);
189                        parameters.forEach((parameter, value) -> {
190                                checkParameters(parameter, value);
191                                map.put(parameter, value);
192                        });
193                        this.parameters = Collections.unmodifiableMap(map);
194                }
195                else {
196                        this.parameters = Collections.emptyMap();
197                }
198        }
199
200        /**
201         * Checks the given token string for illegal characters, as defined in RFC 2616,
202         * section 2.2.
203         * @throws IllegalArgumentException in case of illegal characters
204         * @see <a href="https://tools.ietf.org/html/rfc2616#section-2.2">HTTP 1.1, section 2.2</a>
205         */
206        private void checkToken(String token) {
207                for (int i = 0; i < token.length(); i++) {
208                        char ch = token.charAt(i);
209                        if (!TOKEN.get(ch)) {
210                                throw new IllegalArgumentException("Invalid token character '" + ch + "' in token \"" + token + "\"");
211                        }
212                }
213        }
214
215        protected void checkParameters(String parameter, String value) {
216                Assert.hasLength(parameter, "'parameter' must not be empty");
217                Assert.hasLength(value, "'value' must not be empty");
218                checkToken(parameter);
219                if (PARAM_CHARSET.equals(parameter)) {
220                        if (this.resolvedCharset == null) {
221                                this.resolvedCharset = Charset.forName(unquote(value));
222                        }
223                }
224                else if (!isQuotedString(value)) {
225                        checkToken(value);
226                }
227        }
228
229        private boolean isQuotedString(String s) {
230                if (s.length() < 2) {
231                        return false;
232                }
233                else {
234                        return ((s.startsWith("\"") && s.endsWith("\"")) || (s.startsWith("'") && s.endsWith("'")));
235                }
236        }
237
238        protected String unquote(String s) {
239                return (isQuotedString(s) ? s.substring(1, s.length() - 1) : s);
240        }
241
242        /**
243         * Indicates whether the {@linkplain #getType() type} is the wildcard character
244         * <code>&#42;</code> or not.
245         */
246        public boolean isWildcardType() {
247                return WILDCARD_TYPE.equals(getType());
248        }
249
250        /**
251         * Indicates whether the {@linkplain #getSubtype() subtype} is the wildcard
252         * character <code>&#42;</code> or the wildcard character followed by a suffix
253         * (e.g. <code>&#42;+xml</code>).
254         * @return whether the subtype is a wildcard
255         */
256        public boolean isWildcardSubtype() {
257                return WILDCARD_TYPE.equals(getSubtype()) || getSubtype().startsWith("*+");
258        }
259
260        /**
261         * Indicates whether this MIME Type is concrete, i.e. whether neither the type
262         * nor the subtype is a wildcard character <code>&#42;</code>.
263         * @return whether this MIME Type is concrete
264         */
265        public boolean isConcrete() {
266                return !isWildcardType() && !isWildcardSubtype();
267        }
268
269        /**
270         * Return the primary type.
271         */
272        public String getType() {
273                return this.type;
274        }
275
276        /**
277         * Return the subtype.
278         */
279        public String getSubtype() {
280                return this.subtype;
281        }
282
283        /**
284         * Return the character set, as indicated by a {@code charset} parameter, if any.
285         * @return the character set, or {@code null} if not available
286         * @since 4.3
287         */
288        @Nullable
289        public Charset getCharset() {
290                return this.resolvedCharset;
291        }
292
293        /**
294         * Return a generic parameter value, given a parameter name.
295         * @param name the parameter name
296         * @return the parameter value, or {@code null} if not present
297         */
298        @Nullable
299        public String getParameter(String name) {
300                return this.parameters.get(name);
301        }
302
303        /**
304         * Return all generic parameter values.
305         * @return a read-only map (possibly empty, never {@code null})
306         */
307        public Map<String, String> getParameters() {
308                return this.parameters;
309        }
310
311        /**
312         * Indicate whether this MIME Type includes the given MIME Type.
313         * <p>For instance, {@code text/*} includes {@code text/plain} and {@code text/html},
314         * and {@code application/*+xml} includes {@code application/soap+xml}, etc.
315         * This method is <b>not</b> symmetric.
316         * @param other the reference MIME Type with which to compare
317         * @return {@code true} if this MIME Type includes the given MIME Type;
318         * {@code false} otherwise
319         */
320        public boolean includes(@Nullable MimeType other) {
321                if (other == null) {
322                        return false;
323                }
324                if (isWildcardType()) {
325                        // */* includes anything
326                        return true;
327                }
328                else if (getType().equals(other.getType())) {
329                        if (getSubtype().equals(other.getSubtype())) {
330                                return true;
331                        }
332                        if (isWildcardSubtype()) {
333                                // Wildcard with suffix, e.g. application/*+xml
334                                int thisPlusIdx = getSubtype().lastIndexOf('+');
335                                if (thisPlusIdx == -1) {
336                                        return true;
337                                }
338                                else {
339                                        // application/*+xml includes application/soap+xml
340                                        int otherPlusIdx = other.getSubtype().lastIndexOf('+');
341                                        if (otherPlusIdx != -1) {
342                                                String thisSubtypeNoSuffix = getSubtype().substring(0, thisPlusIdx);
343                                                String thisSubtypeSuffix = getSubtype().substring(thisPlusIdx + 1);
344                                                String otherSubtypeSuffix = other.getSubtype().substring(otherPlusIdx + 1);
345                                                if (thisSubtypeSuffix.equals(otherSubtypeSuffix) && WILDCARD_TYPE.equals(thisSubtypeNoSuffix)) {
346                                                        return true;
347                                                }
348                                        }
349                                }
350                        }
351                }
352                return false;
353        }
354
355        /**
356         * Indicate whether this MIME Type is compatible with the given MIME Type.
357         * <p>For instance, {@code text/*} is compatible with {@code text/plain},
358         * {@code text/html}, and vice versa. In effect, this method is similar to
359         * {@link #includes}, except that it <b>is</b> symmetric.
360         * @param other the reference MIME Type with which to compare
361         * @return {@code true} if this MIME Type is compatible with the given MIME Type;
362         * {@code false} otherwise
363         */
364        public boolean isCompatibleWith(@Nullable MimeType other) {
365                if (other == null) {
366                        return false;
367                }
368                if (isWildcardType() || other.isWildcardType()) {
369                        return true;
370                }
371                else if (getType().equals(other.getType())) {
372                        if (getSubtype().equals(other.getSubtype())) {
373                                return true;
374                        }
375                        // Wildcard with suffix? e.g. application/*+xml
376                        if (isWildcardSubtype() || other.isWildcardSubtype()) {
377                                int thisPlusIdx = getSubtype().lastIndexOf('+');
378                                int otherPlusIdx = other.getSubtype().lastIndexOf('+');
379                                if (thisPlusIdx == -1 && otherPlusIdx == -1) {
380                                        return true;
381                                }
382                                else if (thisPlusIdx != -1 && otherPlusIdx != -1) {
383                                        String thisSubtypeNoSuffix = getSubtype().substring(0, thisPlusIdx);
384                                        String otherSubtypeNoSuffix = other.getSubtype().substring(0, otherPlusIdx);
385                                        String thisSubtypeSuffix = getSubtype().substring(thisPlusIdx + 1);
386                                        String otherSubtypeSuffix = other.getSubtype().substring(otherPlusIdx + 1);
387                                        if (thisSubtypeSuffix.equals(otherSubtypeSuffix) &&
388                                                        (WILDCARD_TYPE.equals(thisSubtypeNoSuffix) || WILDCARD_TYPE.equals(otherSubtypeNoSuffix))) {
389                                                return true;
390                                        }
391                                }
392                        }
393                }
394                return false;
395        }
396
397        /**
398         * Similar to {@link #equals(Object)} but based on the type and subtype
399         * only, i.e. ignoring parameters.
400         * @param other the other mime type to compare to
401         * @return whether the two mime types have the same type and subtype
402         * @since 5.1.4
403         */
404        public boolean equalsTypeAndSubtype(@Nullable MimeType other) {
405                if (other == null) {
406                        return false;
407                }
408                return this.type.equalsIgnoreCase(other.type) && this.subtype.equalsIgnoreCase(other.subtype);
409        }
410
411        /**
412         * Unlike {@link Collection#contains(Object)} which relies on
413         * {@link MimeType#equals(Object)}, this method only checks the type and the
414         * subtype, but otherwise ignores parameters.
415         * @param mimeTypes the list of mime types to perform the check against
416         * @return whether the list contains the given mime type
417         * @since 5.1.4
418         */
419        public boolean isPresentIn(Collection<? extends MimeType> mimeTypes) {
420                for (MimeType mimeType : mimeTypes) {
421                        if (mimeType.equalsTypeAndSubtype(this)) {
422                                return true;
423                        }
424                }
425                return false;
426        }
427
428
429        @Override
430        public boolean equals(@Nullable Object other) {
431                if (this == other) {
432                        return true;
433                }
434                if (!(other instanceof MimeType)) {
435                        return false;
436                }
437                MimeType otherType = (MimeType) other;
438                return (this.type.equalsIgnoreCase(otherType.type) &&
439                                this.subtype.equalsIgnoreCase(otherType.subtype) &&
440                                parametersAreEqual(otherType));
441        }
442
443        /**
444         * Determine if the parameters in this {@code MimeType} and the supplied
445         * {@code MimeType} are equal, performing case-insensitive comparisons
446         * for {@link Charset Charsets}.
447         * @since 4.2
448         */
449        private boolean parametersAreEqual(MimeType other) {
450                if (this.parameters.size() != other.parameters.size()) {
451                        return false;
452                }
453
454                for (Map.Entry<String, String> entry : this.parameters.entrySet()) {
455                        String key = entry.getKey();
456                        if (!other.parameters.containsKey(key)) {
457                                return false;
458                        }
459                        if (PARAM_CHARSET.equals(key)) {
460                                if (!ObjectUtils.nullSafeEquals(getCharset(), other.getCharset())) {
461                                        return false;
462                                }
463                        }
464                        else if (!ObjectUtils.nullSafeEquals(entry.getValue(), other.parameters.get(key))) {
465                                return false;
466                        }
467                }
468
469                return true;
470        }
471
472        @Override
473        public int hashCode() {
474                int result = this.type.hashCode();
475                result = 31 * result + this.subtype.hashCode();
476                result = 31 * result + this.parameters.hashCode();
477                return result;
478        }
479
480        @Override
481        public String toString() {
482                String value = this.toStringValue;
483                if (value == null) {
484                        StringBuilder builder = new StringBuilder();
485                        appendTo(builder);
486                        value = builder.toString();
487                        this.toStringValue = value;
488                }
489                return value;
490        }
491
492        protected void appendTo(StringBuilder builder) {
493                builder.append(this.type);
494                builder.append('/');
495                builder.append(this.subtype);
496                appendTo(this.parameters, builder);
497        }
498
499        private void appendTo(Map<String, String> map, StringBuilder builder) {
500                map.forEach((key, val) -> {
501                        builder.append(';');
502                        builder.append(key);
503                        builder.append('=');
504                        builder.append(val);
505                });
506        }
507
508        /**
509         * Compares this MIME Type to another alphabetically.
510         * @param other the MIME Type to compare to
511         * @see MimeTypeUtils#sortBySpecificity(List)
512         */
513        @Override
514        public int compareTo(MimeType other) {
515                int comp = getType().compareToIgnoreCase(other.getType());
516                if (comp != 0) {
517                        return comp;
518                }
519                comp = getSubtype().compareToIgnoreCase(other.getSubtype());
520                if (comp != 0) {
521                        return comp;
522                }
523                comp = getParameters().size() - other.getParameters().size();
524                if (comp != 0) {
525                        return comp;
526                }
527
528                TreeSet<String> thisAttributes = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
529                thisAttributes.addAll(getParameters().keySet());
530                TreeSet<String> otherAttributes = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
531                otherAttributes.addAll(other.getParameters().keySet());
532                Iterator<String> thisAttributesIterator = thisAttributes.iterator();
533                Iterator<String> otherAttributesIterator = otherAttributes.iterator();
534
535                while (thisAttributesIterator.hasNext()) {
536                        String thisAttribute = thisAttributesIterator.next();
537                        String otherAttribute = otherAttributesIterator.next();
538                        comp = thisAttribute.compareToIgnoreCase(otherAttribute);
539                        if (comp != 0) {
540                                return comp;
541                        }
542                        if (PARAM_CHARSET.equals(thisAttribute)) {
543                                Charset thisCharset = getCharset();
544                                Charset otherCharset = other.getCharset();
545                                if (thisCharset != otherCharset) {
546                                        if (thisCharset == null) {
547                                                return -1;
548                                        }
549                                        if (otherCharset == null) {
550                                                return 1;
551                                        }
552                                        comp = thisCharset.compareTo(otherCharset);
553                                        if (comp != 0) {
554                                                return comp;
555                                        }
556                                }
557                        }
558                        else {
559                                String thisValue = getParameters().get(thisAttribute);
560                                String otherValue = other.getParameters().get(otherAttribute);
561                                if (otherValue == null) {
562                                        otherValue = "";
563                                }
564                                comp = thisValue.compareTo(otherValue);
565                                if (comp != 0) {
566                                        return comp;
567                                }
568                        }
569                }
570
571                return 0;
572        }
573
574        private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
575                // Rely on default serialization, just initialize state after deserialization.
576                ois.defaultReadObject();
577
578                // Initialize transient fields.
579                String charsetName = getParameter(PARAM_CHARSET);
580                if (charsetName != null) {
581                        this.resolvedCharset = Charset.forName(unquote(charsetName));
582                }
583        }
584
585
586        /**
587         * Parse the given String value into a {@code MimeType} object,
588         * with this method name following the 'valueOf' naming convention
589         * (as supported by {@link org.springframework.core.convert.ConversionService}.
590         * @see MimeTypeUtils#parseMimeType(String)
591         */
592        public static MimeType valueOf(String value) {
593                return MimeTypeUtils.parseMimeType(value);
594        }
595
596        private static Map<String, String> addCharsetParameter(Charset charset, Map<String, String> parameters) {
597                Map<String, String> map = new LinkedHashMap<>(parameters);
598                map.put(PARAM_CHARSET, charset.name());
599                return map;
600        }
601
602
603        /**
604         * Comparator to sort {@link MimeType MimeTypes} in order of specificity.
605         *
606         * @param <T> the type of mime types that may be compared by this comparator
607         */
608        public static class SpecificityComparator<T extends MimeType> implements Comparator<T> {
609
610                @Override
611                public int compare(T mimeType1, T mimeType2) {
612                        if (mimeType1.isWildcardType() && !mimeType2.isWildcardType()) {  // */* < audio/*
613                                return 1;
614                        }
615                        else if (mimeType2.isWildcardType() && !mimeType1.isWildcardType()) {  // audio/* > */*
616                                return -1;
617                        }
618                        else if (!mimeType1.getType().equals(mimeType2.getType())) {  // audio/basic == text/html
619                                return 0;
620                        }
621                        else {  // mediaType1.getType().equals(mediaType2.getType())
622                                if (mimeType1.isWildcardSubtype() && !mimeType2.isWildcardSubtype()) {  // audio/* < audio/basic
623                                        return 1;
624                                }
625                                else if (mimeType2.isWildcardSubtype() && !mimeType1.isWildcardSubtype()) {  // audio/basic > audio/*
626                                        return -1;
627                                }
628                                else if (!mimeType1.getSubtype().equals(mimeType2.getSubtype())) {  // audio/basic == audio/wave
629                                        return 0;
630                                }
631                                else {  // mediaType2.getSubtype().equals(mediaType2.getSubtype())
632                                        return compareParameters(mimeType1, mimeType2);
633                                }
634                        }
635                }
636
637                protected int compareParameters(T mimeType1, T mimeType2) {
638                        int paramsSize1 = mimeType1.getParameters().size();
639                        int paramsSize2 = mimeType2.getParameters().size();
640                        return Integer.compare(paramsSize2, paramsSize1);  // audio/basic;level=1 < audio/basic
641                }
642        }
643
644}